diff --git a/electron-builder.yml b/electron-builder.yml index 2f2f6a21e..21aa1fe28 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,60 +134,116 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-beta.2 + What's New in v1.7.0-beta.3 New Features: - - Session Settings: Manage session-specific settings and model configurations independently - - Notes Full-Text Search: Search across all notes with match highlighting - - Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) - - Intel OV OCR: Hardware-accelerated OCR using Intel NPU - - Auto-start API Server: Automatically starts when agents exist + - Enhanced Tool Permission System: Real-time tool approval interface with improved UX + - Plugin Management System: Support for Claude Agent plugins (agents, commands, skills) + - Skill Tool: Add skill execution capabilities for agents + - Mobile App Data Restore: Support restoring data to mobile applications + - OpenMinerU Preprocessor: Knowledge base now supports open-source MinerU for document processing + - HuggingFace Provider: Added HuggingFace as AI provider + - Claude Haiku 4.5: Support for the latest Claude Haiku 4.5 model + - Ling Series Models: Added support for Ling-1T and related models + - Intel OVMS Painting: New painting provider using Intel OpenVINO Model Server + - Automatic Update Checks: Implement periodic update checking with configurable intervals + - HuggingChat Mini App: New mini app for HuggingChat integration Improvements: - - Agent model selection now requires explicit user choice - - Added Mistral AI provider support - - Added NewAPI generic provider support - - Improved navbar layout consistency across different modes - - Enhanced chat component responsiveness - - Better code block display on small screens - - Updated OVMS to 2025.3 official release - - Added Greek language support + - Agent Creation: New agents are now automatically activated upon creation + - Lazy Loading: Optimize page load performance with route lazy loading + - UI Enhancements: Improved agent item styling and layout consistency + - Navigation: Better navbar layout for fullscreen mode on macOS + - Settings Tab: Enhanced context slider consistency + - Backup Manager: Unified footer layout for local and S3 backup managers + - Menu System: Enhanced application menu with improved help section + - Proxy Rules: Comprehensive proxy bypass rule matching + - German Language: Added German language support + - MCP Confirmation: Added confirmation modal when activating protocol-installed MCP servers + - Translation: Enhanced translation script with concurrency and validation + - Electron & Vite: Updated to Electron 38 and Vite 4.0.1 + + Claude Code Tool Improvements: + - GlobTool: Now counts lines instead of files in output for better clarity + - ReadTool: Automatically removes system reminder tags from output + - TodoWriteTool: Improved rendering behavior + - Environment Variables: Updated model-related environment variable names Bug Fixes: - - Fixed GitHub Copilot gpt-5-codex streaming issues - - Fixed assistant creation failures - - Fixed translate auto-copy functionality - - Fixed miniapps external link opening - - Fixed message layout and overflow issues - - Fixed API key parsing to preserve spaces - - Fixed agent display in different navbar layouts + - Fixed session model not being used when sending messages + - Fixed tool approval UI and shared workspace plugin inconsistencies + - Fixed API server readiness notification to renderer + - Fixed grouped items not respecting saved tag order + - Fixed assistant/agent activation when creating new ones + - Fixed Dashscope Anthropic API host and migrated old configs + - Fixed Qwen3 thinking mode control for Ollama + - Fixed disappeared MCP button + - Fixed create assistant causing blank screen + - Fixed up-down button visibility in some cases + - Fixed hooks preventing save on composing enter key + - Fixed Azure GPT-image-1 and OpenRouter Gemini-image + - Fixed Silicon reasoning issues + - Fixed topic branch incomplete copy with two-pass ID mapping + - Fixed deep research model search context restrictions + - Fixed model capability checking logic + - Fixed reranker API error response capture + - Fixed right-click paste file content into inputbar + - Fixed minimax-m2 support in aiCore - v1.7.0-beta.2 新特性 + v1.7.0-beta.3 新特性 新功能: - - 会话设置:独立管理会话特定的设置和模型配置 - - 笔记全文搜索:跨所有笔记搜索并高亮匹配内容 - - 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区) - - Intel OV OCR:使用 Intel NPU 的硬件加速 OCR - - 自动启动 API 服务器:当存在 Agent 时自动启动 + - 增强工具权限系统:实时工具审批界面,改进用户体验 + - 插件管理系统:支持 Claude Agent 插件(agents、commands、skills) + - 技能工具:为 Agent 添加技能执行能力 + - 移动应用数据恢复:支持将数据恢复到移动应用程序 + - OpenMinerU 预处理器:知识库现支持使用开源 MinerU 处理文档 + - HuggingFace 提供商:添加 HuggingFace 作为 AI 提供商 + - Claude Haiku 4.5:支持最新的 Claude Haiku 4.5 模型 + - Ling 系列模型:添加 Ling-1T 及相关模型支持 + - Intel OVMS 绘图:使用 Intel OpenVINO 模型服务器的新绘图提供商 + - 自动更新检查:实现可配置间隔的定期更新检查 + - HuggingChat 小程序:新增 HuggingChat 集成小程序 改进: - - Agent 模型选择现在需要用户显式选择 - - 添加 Mistral AI 提供商支持 - - 添加 NewAPI 通用提供商支持 - - 改进不同模式下的导航栏布局一致性 - - 增强聊天组件响应式设计 - - 优化小屏幕代码块显示 - - 更新 OVMS 至 2025.3 正式版 - - 添加希腊语支持 + - Agent 创建:新创建的 Agent 现在会自动激活 + - 懒加载:通过路由懒加载优化页面加载性能 + - UI 增强:改进 Agent 项目样式和布局一致性 + - 导航:改进 macOS 全屏模式下的导航栏布局 + - 设置选项卡:增强上下文滑块一致性 + - 备份管理器:统一本地和 S3 备份管理器的页脚布局 + - 菜单系统:增强应用菜单,改进帮助部分 + - 代理规则:全面的代理绕过规则匹配 + - 德语支持:添加德语语言支持 + - MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框 + - 翻译:增强翻译脚本的并发和验证功能 + - Electron & Vite:更新至 Electron 38 和 Vite 4.0.1 + + Claude Code 工具改进: + - GlobTool:现在计算行数而不是文件数,提供更清晰的输出 + - ReadTool:自动从输出中移除系统提醒标签 + - TodoWriteTool:改进渲染行为 + - 环境变量:更新模型相关的环境变量名称 问题修复: - - 修复 GitHub Copilot gpt-5-codex 流式传输问题 - - 修复助手创建失败 - - 修复翻译自动复制功能 - - 修复小程序外部链接打开 - - 修复消息布局和溢出问题 - - 修复 API 密钥解析以保留空格 - - 修复不同导航栏布局中的 Agent 显示 + - 修复发送消息时未使用会话模型 + - 修复工具审批 UI 和共享工作区插件不一致 + - 修复 API 服务器就绪通知到渲染器 + - 修复分组项目不遵守已保存标签顺序 + - 修复创建新的助手/Agent 时的激活问题 + - 修复 Dashscope Anthropic API 主机并迁移旧配置 + - 修复 Ollama 的 Qwen3 思考模式控制 + - 修复 MCP 按钮消失 + - 修复创建助手导致空白屏幕 + - 修复某些情况下上下按钮可见性 + - 修复钩子在输入法输入时阻止保存 + - 修复 Azure GPT-image-1 和 OpenRouter Gemini-image + - 修复 Silicon 推理问题 + - 修复主题分支不完整复制,采用两阶段 ID 映射 + - 修复深度研究模型搜索上下文限制 + - 修复模型能力检查逻辑 + - 修复 reranker API 错误响应捕获 + - 修复右键粘贴文件内容到输入栏 + - 修复 aiCore 中的 minimax-m2 支持 diff --git a/package.json b/package.json index 2957520e2..aa59cdcff 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,10 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", + "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", + "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a5a9f54f9..5f933a2cd 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -354,6 +354,7 @@ export enum IpcChannel { ApiServer_Stop = 'api-server:stop', ApiServer_Restart = 'api-server:restart', ApiServer_GetStatus = 'api-server:get-status', + ApiServer_Ready = 'api-server:ready', // NOTE: This api is not be used. ApiServer_GetConfig = 'api-server:get-config', @@ -395,5 +396,12 @@ export enum IpcChannel { ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed', ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache', ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', - ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content' + ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', + + // WebSocket + WebSocket_Start = 'webSocket:start', + WebSocket_Stop = 'webSocket:stop', + WebSocket_Status = 'webSocket:status', + WebSocket_SendFile = 'webSocket:send-file', + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' } diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts index 3cb81f412..9b15e56da 100644 --- a/src/main/apiServer/server.ts +++ b/src/main/apiServer/server.ts @@ -1,8 +1,10 @@ import { createServer } from 'node:http' import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' import { agentService } from '../services/agents' +import { windowService } from '../services/WindowService' import { app } from './app' import { config } from './config' @@ -43,6 +45,13 @@ export class ApiServer { return new Promise((resolve, reject) => { this.server!.listen(port, host, () => { logger.info('API server started', { host, port }) + + // Notify renderer that API server is ready + const mainWindow = windowService.getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IpcChannel.ApiServer_Ready) + } + resolve() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 709bf2483..67bca3db3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -30,6 +30,7 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC import fontList from 'font-list' import { agentMessageRepository } from './services/agents/database' +import { PluginService } from './services/agents/plugins/PluginService' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' @@ -50,7 +51,6 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ocrService } from './services/ocr/OcrService' import OvmsManager from './services/OvmsManager' -import { PluginService } from './services/PluginService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' @@ -72,6 +72,7 @@ import { } from './services/SpanCacheService' import storeSyncService from './services/StoreSyncService' import VertexAIService from './services/VertexAIService' +import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -1021,6 +1022,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) + // WebSocket + ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) + ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) + ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) + ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) + ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) + // Preference handlers PreferenceService.registerIpcHandler() } diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts deleted file mode 100644 index 8ff182020..000000000 --- a/src/main/services/PluginService.ts +++ /dev/null @@ -1,1171 +0,0 @@ -import { loggerService } from '@logger' -import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' -import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' -import type { - AgentEntity, - InstalledPlugin, - InstallPluginOptions, - ListAvailablePluginsResult, - PluginError, - PluginMetadata, - PluginType, - UninstallPluginOptions -} from '@types' -import * as crypto from 'crypto' -import { app } from 'electron' -import * as fs from 'fs' -import * as path from 'path' - -import { AgentService } from './agents/services/AgentService' - -const logger = loggerService.withContext('PluginService') - -interface PluginServiceConfig { - maxFileSize: number // bytes - cacheTimeout: number // milliseconds -} - -/** - * PluginService manages agent and command plugins from resources directory. - * - * Features: - * - Singleton pattern for consistent state management - * - Caching of available plugins for performance - * - Security validation (path traversal, file size, extensions) - * - Transactional install/uninstall operations - * - Integration with AgentService for metadata persistence - */ -export class PluginService { - private static instance: PluginService | null = null - - private availablePluginsCache: ListAvailablePluginsResult | null = null - private cacheTimestamp = 0 - private config: PluginServiceConfig - - private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] - - private constructor(config?: Partial) { - this.config = { - maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default - cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default - } - - logger.info('PluginService initialized', { - maxFileSize: this.config.maxFileSize, - cacheTimeout: this.config.cacheTimeout - }) - } - - /** - * Get singleton instance - */ - static getInstance(config?: Partial): PluginService { - if (!PluginService.instance) { - PluginService.instance = new PluginService(config) - } - return PluginService.instance - } - - /** - * List all available plugins from resources directory (with caching) - */ - async listAvailable(): Promise { - const now = Date.now() - - // Return cached data if still valid - if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { - logger.debug('Returning cached plugin list', { - cacheAge: now - this.cacheTimestamp - }) - return this.availablePluginsCache - } - - logger.info('Scanning available plugins') - - // Scan all plugin types - const [agents, commands, skills] = await Promise.all([ - this.scanPluginDirectory('agent'), - this.scanPluginDirectory('command'), - this.scanSkillDirectory() - ]) - - const result: ListAvailablePluginsResult = { - agents, - commands, - skills, // NEW: include skills - total: agents.length + commands.length + skills.length - } - - // Update cache - this.availablePluginsCache = result - this.cacheTimestamp = now - - logger.info('Available plugins scanned', { - agentsCount: agents.length, - commandsCount: commands.length, - skillsCount: skills.length, - total: result.total - }) - - return result - } - - /** - * Install plugin with validation and transactional safety - */ - async install(options: InstallPluginOptions): Promise { - logger.info('Installing plugin', options) - - // Validate source path - this.validateSourcePath(options.sourcePath) - - // Get agent and validate - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - await this.validateWorkdir(workdir, options.agentId) - - // Get absolute source path - const basePath = this.getPluginsBasePath() - const sourceAbsolutePath = path.join(basePath, options.sourcePath) - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // Validate skill folder exists and is a directory - try { - const stats = await fs.promises.stat(sourceAbsolutePath) - if (!stats.isDirectory()) { - throw { - type: 'INVALID_METADATA', - reason: 'Skill source is not a directory', - path: options.sourcePath - } as PluginError - } - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourceAbsolutePath - } as PluginError - } - - // Parse metadata from SKILL.md - const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') - - // Sanitize folder name (different rules than file names) - const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) - - // Ensure .claude/skills directory exists - await this.ensureClaudeDirectory(workdir, 'skill') - - // Construct destination path (folder, not file) - const destPath = path.join(workdir, '.claude', 'skills', sanitizedFolderName) - - // Update metadata with sanitized folder name - metadata.filename = sanitizedFolderName - - // Execute skill-specific install - await this.installSkill(agent, sourceAbsolutePath, destPath, metadata) - - logger.info('Skill installed successfully', { - agentId: options.agentId, - sourcePath: options.sourcePath, - folderName: sanitizedFolderName - }) - - return { - ...metadata, - installedAt: Date.now() - } - } - - // EXISTING LOGIC for agents/commands (unchanged) - // Files go through existing validation and sanitization - await this.validatePluginFile(sourceAbsolutePath) - - // Parse metadata - const category = path.basename(path.dirname(options.sourcePath)) - const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, options.type) - - // Sanitize filename - const sanitizedFilename = this.sanitizeFilename(metadata.filename) - - // Ensure .claude directory exists - await this.ensureClaudeDirectory(workdir, options.type) - - // Get destination path - const destDir = path.join(workdir, '.claude', options.type === 'agent' ? 'agents' : 'commands') - const destPath = path.join(destDir, sanitizedFilename) - - // Check for duplicate and auto-uninstall if exists - const existingPlugins = agent.configuration?.installed_plugins || [] - const existingPlugin = existingPlugins.find((p) => p.filename === sanitizedFilename && p.type === options.type) - - if (existingPlugin) { - logger.info('Plugin already installed, auto-uninstalling old version', { - filename: sanitizedFilename - }) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - // Re-fetch agent after uninstall - const updatedAgent = await AgentService.getInstance().getAgent(options.agentId) - if (!updatedAgent) { - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: 'Agent not found after uninstall' - } as PluginError - } - - await this.installTransaction(updatedAgent, sourceAbsolutePath, destPath, metadata) - } else { - await this.installTransaction(agent, sourceAbsolutePath, destPath, metadata) - } - - logger.info('Plugin installed successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - - return { - ...metadata, - filename: sanitizedFilename, - installedAt: Date.now() - } - } - - /** - * Uninstall plugin with cleanup - */ - async uninstall(options: UninstallPluginOptions): Promise { - logger.info('Uninstalling plugin', options) - - // Get agent - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // For skills, filename is the folder name (no extension) - // Use sanitizeFolderName to ensure consistency - const sanitizedFolderName = this.sanitizeFolderName(options.filename) - await this.uninstallSkill(agent, sanitizedFolderName) - - logger.info('Skill uninstalled successfully', { - agentId: options.agentId, - folderName: sanitizedFolderName - }) - - return - } - - // EXISTING LOGIC for agents/commands (unchanged) - // For files, filename includes .md extension - const sanitizedFilename = this.sanitizeFilename(options.filename) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - logger.info('Plugin uninstalled successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - } - - /** - * List installed plugins for an agent (from database + filesystem validation) - */ - async listInstalled(agentId: string): Promise { - logger.debug('Listing installed plugins', { agentId }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const installedPlugins = agent.configuration?.installed_plugins || [] - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - logger.warn('Agent has no accessible paths', { agentId }) - return [] - } - - // Validate each plugin still exists on filesystem - const validatedPlugins: InstalledPlugin[] = [] - - for (const plugin of installedPlugins) { - // Get plugin path based on type - let pluginPath: string - if (plugin.type === 'skill') { - pluginPath = path.join(workdir, '.claude', 'skills', plugin.filename) - } else { - pluginPath = path.join(workdir, '.claude', plugin.type === 'agent' ? 'agents' : 'commands', plugin.filename) - } - - try { - const stats = await fs.promises.stat(pluginPath) - - // For files (agents/commands), verify file hash if stored - if (plugin.type !== 'skill' && plugin.contentHash) { - const currentHash = await this.calculateFileHash(pluginPath) - if (currentHash !== plugin.contentHash) { - logger.warn('Plugin file hash mismatch', { - filename: plugin.filename, - expected: plugin.contentHash, - actual: currentHash - }) - } - } - - // For skills, stats.size is folder size (handled differently) - // For files, stats.size is file size - validatedPlugins.push({ - filename: plugin.filename, - type: plugin.type, - metadata: { - sourcePath: plugin.sourcePath, - filename: plugin.filename, - name: plugin.name, - description: plugin.description, - allowed_tools: plugin.allowed_tools, - tools: plugin.tools, - category: plugin.category || '', - type: plugin.type, - tags: plugin.tags, - version: plugin.version, - author: plugin.author, - size: stats.size, - contentHash: plugin.contentHash, - installedAt: plugin.installedAt, - updatedAt: plugin.updatedAt - } - }) - } catch (error) { - logger.warn('Plugin not found on filesystem', { - filename: plugin.filename, - path: pluginPath, - error: error instanceof Error ? error.message : String(error) - }) - } - } - - logger.debug('Listed installed plugins', { - agentId, - count: validatedPlugins.length - }) - - return validatedPlugins - } - - /** - * Invalidate plugin cache (for development/testing) - */ - invalidateCache(): void { - this.availablePluginsCache = null - this.cacheTimestamp = 0 - logger.info('Plugin cache invalidated') - } - - /** - * Read plugin content from source (resources directory) - */ - async readContent(sourcePath: string): Promise { - logger.info('Reading plugin content', { sourcePath }) - - // Validate source path - this.validateSourcePath(sourcePath) - - // Get absolute path - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, sourcePath) - - // Validate file exists and is accessible - try { - await fs.promises.access(absolutePath, fs.constants.R_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourcePath - } as PluginError - } - - // Read content - try { - const content = await fs.promises.readFile(absolutePath, 'utf8') - logger.debug('Plugin content read successfully', { - sourcePath, - size: content.length - }) - return content - } catch (error) { - throw { - type: 'READ_FAILED', - path: sourcePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Write plugin content to installed plugin (in agent's .claude directory) - * Note: Only works for file-based plugins (agents/commands), not skills - */ - async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { - logger.info('Writing plugin content', { agentId, filename, type }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - // Check if plugin is installed - const installedPlugins = agent.configuration?.installed_plugins || [] - const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) - - if (!installedPlugin) { - throw { - type: 'PLUGIN_NOT_INSTALLED', - filename, - agentId - } as PluginError - } - - // Get file path - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Verify file exists - try { - await fs.promises.access(filePath, fs.constants.W_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Write content - try { - await fs.promises.writeFile(filePath, content, 'utf8') - logger.debug('Plugin content written successfully', { - filePath, - size: content.length - }) - - // Update content hash in database - const newContentHash = crypto.createHash('sha256').update(content).digest('hex') - const updatedPlugins = installedPlugins.map((p) => { - if (p.filename === filename && p.type === type) { - return { - ...p, - contentHash: newContentHash, - updatedAt: Date.now() - } - } - return p - }) - - await AgentService.getInstance().updateAgent(agentId, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.info('Plugin content updated successfully', { - agentId, - filename, - type, - newContentHash - }) - } catch (error) { - throw { - type: 'WRITE_FAILED', - path: filePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Get absolute path to plugins directory (handles packaged vs dev) - */ - private getPluginsBasePath(): string { - // Use the utility function which handles both dev and production correctly - if (app.isPackaged) { - return path.join(process.resourcesPath, 'claude-code-plugins') - } - return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') - } - - /** - * Scan plugin directory and return metadata for all plugins - */ - private async scanPluginDirectory(type: 'agent' | 'command'): Promise { - const basePath = this.getPluginsBasePath() - const typeDir = path.join(basePath, type === 'agent' ? 'agents' : 'commands') - - try { - await fs.promises.access(typeDir, fs.constants.R_OK) - } catch (error) { - logger.warn(`Plugin directory not accessible: ${typeDir}`, { - error: error instanceof Error ? error.message : String(error) - }) - return [] - } - - const plugins: PluginMetadata[] = [] - const categories = await fs.promises.readdir(typeDir, { withFileTypes: true }) - - for (const categoryEntry of categories) { - if (!categoryEntry.isDirectory()) { - continue - } - - const category = categoryEntry.name - const categoryPath = path.join(typeDir, category) - const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) - - for (const file of files) { - if (!file.isFile()) { - continue - } - - const ext = path.extname(file.name).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - continue - } - - try { - const filePath = path.join(categoryPath, file.name) - const sourcePath = path.join(type === 'agent' ? 'agents' : 'commands', category, file.name) - - const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) - plugins.push(metadata) - } catch (error) { - logger.warn(`Failed to parse plugin: ${file.name}`, { - category, - error: error instanceof Error ? error.message : String(error) - }) - } - } - } - - return plugins - } - - /** - * Scan skills directory for skill folders (recursively) - */ - private async scanSkillDirectory(): Promise { - const basePath = this.getPluginsBasePath() - const skillsPath = path.join(basePath, 'skills') - - const skills: PluginMetadata[] = [] - - try { - // Check if skills directory exists - try { - await fs.promises.access(skillsPath) - } catch { - logger.warn('Skills directory not found', { skillsPath }) - return [] - } - - // Recursively find all directories containing SKILL.md - const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) - - logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) - - // Parse metadata for each skill directory - for (const { folderPath, sourcePath } of skillDirectories) { - try { - const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') - skills.push(metadata) - } catch (error) { - logger.warn(`Failed to parse skill folder: ${sourcePath}`, { - folderPath, - error: error instanceof Error ? error.message : String(error) - }) - // Continue with other skills - } - } - } catch (error) { - logger.error('Failed to scan skill directory', { skillsPath, error }) - // Return empty array on error - } - - return skills - } - - /** - * Validate source path to prevent path traversal attacks - */ - private validateSourcePath(sourcePath: string): void { - // Remove any path traversal attempts - const normalized = path.normalize(sourcePath) - - // Ensure no parent directory access - if (normalized.includes('..')) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path traversal detected', - path: sourcePath - } as PluginError - } - - // Ensure path is within plugins directory - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, normalized) - const resolvedPath = path.resolve(absolutePath) - - if (!resolvedPath.startsWith(path.resolve(basePath))) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path outside plugins directory', - path: sourcePath - } as PluginError - } - } - - /** - * Validate workdir against agent's accessible paths - */ - private async validateWorkdir(workdir: string, agentId: string): Promise { - // Get agent from database - const agent = await AgentService.getInstance().getAgent(agentId) - - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Agent not found' - } as PluginError - } - - // Verify workdir is in agent's accessible_paths - if (!agent.accessible_paths?.includes(workdir)) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Workdir not in agent accessible paths' - } as PluginError - } - - // Verify workdir exists and is accessible - try { - await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) - } catch (error) { - throw { - type: 'WORKDIR_NOT_FOUND', - workdir, - message: 'Workdir does not exist or is not accessible' - } as PluginError - } - } - - /** - * Sanitize filename to remove unsafe characters (for agents/commands) - */ - private sanitizeFilename(filename: string): string { - // Remove path separators - let sanitized = filename.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore, dot) - sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') - - // Ensure .md extension - if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { - sanitized += '.md' - } - - return sanitized - } - - /** - * Sanitize folder name for skills (different rules than file names) - * NO dots allowed to avoid confusion with file extensions - */ - private sanitizeFolderName(folderName: string): string { - // Remove path separators - let sanitized = folderName.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore) - // NOTE: No dots allowed to avoid confusion with file extensions - sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') - - // Validate no extension was provided - if (folderName.includes('.')) { - logger.warn('Skill folder name contained dots, sanitized', { - original: folderName, - sanitized - }) - } - - return sanitized - } - - /** - * Validate plugin file (size, extension, frontmatter) - */ - private async validatePluginFile(filePath: string): Promise { - // Check file exists - let stats: fs.Stats - try { - stats = await fs.promises.stat(filePath) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Check file size - if (stats.size > this.config.maxFileSize) { - throw { - type: 'FILE_TOO_LARGE', - size: stats.size, - max: this.config.maxFileSize - } as PluginError - } - - // Check file extension - const ext = path.extname(filePath).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - throw { - type: 'INVALID_FILE_TYPE', - extension: ext - } as PluginError - } - - // Validate frontmatter can be parsed safely - // This is handled by parsePluginMetadata which uses FAILSAFE_SCHEMA - try { - const category = path.basename(path.dirname(filePath)) - const sourcePath = path.relative(this.getPluginsBasePath(), filePath) - const type = sourcePath.startsWith('agents') ? 'agent' : 'command' - - await parsePluginMetadata(filePath, sourcePath, category, type) - } catch (error) { - throw { - type: 'INVALID_METADATA', - reason: 'Failed to parse frontmatter', - path: filePath - } as PluginError - } - } - - /** - * Calculate SHA-256 hash of file - */ - private async calculateFileHash(filePath: string): Promise { - const content = await fs.promises.readFile(filePath, 'utf8') - return crypto.createHash('sha256').update(content).digest('hex') - } - - /** - * Ensure .claude subdirectory exists for the given plugin type - */ - private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { - const claudeDir = path.join(workdir, '.claude') - - let subDir: string - if (type === 'agent') { - subDir = 'agents' - } else if (type === 'command') { - subDir = 'commands' - } else if (type === 'skill') { - subDir = 'skills' - } else { - throw new Error(`Unknown plugin type: ${type}`) - } - - const typeDir = path.join(claudeDir, subDir) - - try { - await fs.promises.mkdir(typeDir, { recursive: true }) - logger.debug('Ensured directory exists', { typeDir }) - } catch (error) { - logger.error('Failed to create directory', { - typeDir, - error: error instanceof Error ? error.message : String(error) - }) - throw { - type: 'PERMISSION_DENIED', - path: typeDir - } as PluginError - } - } - - /** - * Transactional install operation - * Steps: - * 1. Copy to temp location - * 2. Update database - * 3. Move to final location (atomic) - * Rollback on error - */ - private async installTransaction( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const tempPath = `${destPath}.tmp` - let fileCopied = false - - try { - // Step 1: Copy file to temporary location - await fs.promises.copyFile(sourceAbsolutePath, tempPath) - fileCopied = true - logger.debug('File copied to temp location', { tempPath }) - - // Step 2: Update agent configuration in database - const existingPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = [ - ...existingPlugins, - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, - type: metadata.type, - name: metadata.name, - description: metadata.description, - allowed_tools: metadata.allowed_tools, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 3: Move temp file to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logger.debug('File moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp file if it exists - if (fileCopied) { - try { - await fs.promises.unlink(tempPath) - logger.debug('Rolled back temp file', { tempPath }) - } catch (unlinkError) { - logger.error('Failed to rollback temp file', { - tempPath, - error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Transactional uninstall operation - * Steps: - * 1. Update database - * 2. Delete file - * Rollback database on error - */ - private async uninstallTransaction(agent: AgentEntity, filename: string, type: 'agent' | 'command'): Promise { - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Step 1: Update database first (easier to rollback file operations) - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === filename && p.type === type)) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete file - try { - await fs.promises.unlink(filePath) - logger.debug('Plugin file deleted', { filePath }) - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code !== 'ENOENT') { - throw error // File should exist, re-throw if not ENOENT - } - logger.warn('Plugin file already deleted', { filePath }) - } - } catch (error) { - // Rollback: restore database if file deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logger.debug('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logger.error('Failed to rollback database', { - agentId: agent.id, - error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Install a skill (copy entire folder) - */ - private async installSkill( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const logContext = logger.withContext('installSkill') - - // Step 1: If destination exists, remove it first (overwrite behavior) - try { - await fs.promises.access(destPath) - // Exists - remove it - await deleteDirectoryRecursive(destPath) - logContext.info('Removed existing skill folder', { destPath }) - } catch { - // Doesn't exist - nothing to remove - } - - // Step 2: Copy folder to temporary location - const tempPath = `${destPath}.tmp` - let folderCopied = false - - try { - // Copy to temp location - await copyDirectoryRecursive(sourceAbsolutePath, tempPath) - folderCopied = true - logContext.info('Skill folder copied to temp location', { tempPath }) - - // Step 3: Update agent configuration in database - const updatedPlugins = [ - ...(agent.configuration?.installed_plugins || []).filter( - (p) => !(p.filename === metadata.filename && p.type === 'skill') - ), - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, // Folder name, no extension - type: metadata.type, - name: metadata.name, - description: metadata.description, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 4: Move temp folder to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logContext.info('Skill folder moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp folder if it exists - if (folderCopied) { - try { - await deleteDirectoryRecursive(tempPath) - logContext.info('Rolled back temp folder', { tempPath }) - } catch (unlinkError) { - logContext.error('Failed to rollback temp folder', { tempPath, error: unlinkError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Uninstall a skill (remove entire folder) - */ - private async uninstallSkill(agent: AgentEntity, folderName: string): Promise { - const logContext = logger.withContext('uninstallSkill') - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const skillPath = path.join(workdir, '.claude', 'skills', folderName) - - // Step 1: Update database first - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === folderName && p.type === 'skill')) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete folder - try { - await deleteDirectoryRecursive(skillPath) - logContext.info('Skill folder deleted', { skillPath }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error // Folder should exist, re-throw if not ENOENT - } - logContext.warn('Skill folder already deleted', { skillPath }) - } - } catch (error) { - // Rollback: restore database if folder deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logContext.info('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logContext.error('Failed to rollback database', { agentId: agent.id, error: rollbackError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } -} - -export const pluginService = PluginService.getInstance() diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts new file mode 100644 index 000000000..f81251717 --- /dev/null +++ b/src/main/services/WebSocketService.ts @@ -0,0 +1,369 @@ +import { loggerService } from '@logger' +import * as fs from 'fs' +import { networkInterfaces } from 'os' +import * as path from 'path' +import type { Socket } from 'socket.io' +import { Server } from 'socket.io' + +import { windowService } from './WindowService' + +const logger = loggerService.withContext('WebSocketService') + +class WebSocketService { + private io: Server | null = null + private isStarted = false + private port = 7017 + private connectedClients = new Set() + + private getLocalIpAddress(): string | undefined { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ interface: string; address: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + interface: name, + address: iface.address, + priority + }) + } + } + } + + if (candidates.length === 0) { + logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') + return '127.0.0.1' + } + + // 按优先级排序,选择优先级最高的 + candidates.sort((a, b) => a.priority - b.priority) + const best = candidates[0] + + logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) + return best.address + } + + public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { + if (this.isStarted && this.io) { + return { success: true, port: this.port } + } + + try { + this.io = new Server(this.port, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, + transports: ['websocket', 'polling'], + allowEIO3: true, + pingTimeout: 60000, + pingInterval: 25000 + }) + + this.io.on('connection', (socket: Socket) => { + this.connectedClients.add(socket.id) + + const mainWindow = windowService.getMainWindow() + if (!mainWindow) { + logger.error('Main window is null, cannot send connection event') + } else { + mainWindow.webContents.send('websocket-client-connected', { + connected: true, + clientId: socket.id + }) + logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) + } + + socket.on('message', (data) => { + logger.info('Received message from mobile:', data) + mainWindow?.webContents.send('websocket-message-received', data) + socket.emit('message_received', { success: true }) + }) + + socket.on('disconnect', () => { + logger.info(`Client disconnected: ${socket.id}`) + this.connectedClients.delete(socket.id) + + if (this.connectedClients.size === 0) { + mainWindow?.webContents.send('websocket-client-connected', { + connected: false, + clientId: socket.id + }) + } + }) + }) + + // Engine 层面的事件监听 + this.io.engine.on('connection_error', (err) => { + logger.error('Engine connection error:', err) + }) + + this.io.engine.on('connection', (rawSocket) => { + const remoteAddr = rawSocket.request.connection.remoteAddress + logger.info(`[Engine] Raw connection from: ${remoteAddr}`) + logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) + + rawSocket.on('packet', (packet: { type: string; data?: any }) => { + logger.info( + `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, + packet.data ? { data: packet.data } : {} + ) + }) + + rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { + logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) + }) + + rawSocket.on('close', (reason: string) => { + logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) + }) + + rawSocket.on('error', (error: Error) => { + logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) + }) + }) + + // Socket.IO 握手失败监听 + this.io.on('connection_error', (err) => { + logger.error('[Socket.IO] Connection error during handshake:', err) + }) + + this.isStarted = true + logger.info(`WebSocket server started on port ${this.port}`) + + return { success: true, port: this.port } + } catch (error) { + logger.error('Failed to start WebSocket server:', error as Error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + public stop = async (): Promise<{ success: boolean }> => { + if (!this.isStarted || !this.io) { + return { success: true } + } + + try { + await new Promise((resolve) => { + this.io!.close(() => { + resolve() + }) + }) + + this.io = null + this.isStarted = false + this.connectedClients.clear() + logger.info('WebSocket server stopped') + + return { success: true } + } catch (error) { + logger.error('Failed to stop WebSocket server:', error as Error) + return { success: false } + } + } + + public getStatus = async (): Promise<{ + isRunning: boolean + port?: number + ip?: string + clientConnected: boolean + }> => { + return { + isRunning: this.isStarted, + port: this.isStarted ? this.port : undefined, + ip: this.isStarted ? this.getLocalIpAddress() : undefined, + clientConnected: this.connectedClients.size > 0 + } + } + + public getAllCandidates = async (): Promise< + Array<{ + host: string + interface: string + priority: number + }> + > => { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ host: string; interface: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + host: iface.address, + interface: name, + priority + }) + + logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) + } + } + } + + // 按优先级排序返回 + candidates.sort((a, b) => a.priority - b.priority) + logger.info( + `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` + ) + return candidates + } + + public sendFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string + ): Promise<{ success: boolean; error?: string }> => { + if (!this.isStarted || !this.io) { + const errorMsg = 'WebSocket server is not running.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + if (this.connectedClients.size === 0) { + const errorMsg = 'No client connected.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + const mainWindow = windowService.getMainWindow() + + return new Promise((resolve, reject) => { + const stats = fs.statSync(filePath) + const totalSize = stats.size + const filename = path.basename(filePath) + const stream = fs.createReadStream(filePath) + let bytesSent = 0 + const startTime = Date.now() + + logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) + + // 向客户端发送文件开始的信号,包含文件名和总大小 + this.io!.emit('zip-file-start', { filename, totalSize }) + + stream.on('data', (chunk) => { + bytesSent += chunk.length + const progress = (bytesSent / totalSize) * 100 + + // 向客户端发送文件块 + this.io!.emit('zip-file-chunk', chunk) + + // 向渲染进程发送进度更新 + mainWindow?.webContents.send('file-send-progress', { progress }) + + // 每10%记录一次进度 + if (Math.floor(progress) % 10 === 0) { + const elapsed = (Date.now() - startTime) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) + } + }) + + stream.on('end', () => { + const totalTime = (Date.now() - startTime) / 1000 + const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 + logger.info( + `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` + ) + + // 确保发送100%的进度 + mainWindow?.webContents.send('file-send-progress', { progress: 100 }) + // 向客户端发送文件结束的信号 + this.io!.emit('zip-file-end') + resolve({ success: true }) + }) + + stream.on('error', (error) => { + logger.error(`File transfer failed: ${filename}`, error) + reject({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + }) + }) + } + + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } +} + +export default new WebSocketService() diff --git a/src/main/services/agents/plugins/PluginCacheStore.ts b/src/main/services/agents/plugins/PluginCacheStore.ts new file mode 100644 index 000000000..77b427f4e --- /dev/null +++ b/src/main/services/agents/plugins/PluginCacheStore.ts @@ -0,0 +1,426 @@ +import { loggerService } from '@logger' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types' +import { CachedPluginsDataSchema } from '@types' +import * as fs from 'fs' +import * as path from 'path' + +const logger = loggerService.withContext('PluginCacheStore') + +interface PluginCacheStoreDeps { + allowedExtensions: string[] + getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills' + getClaudeBasePath: (workdir: string) => string + getClaudePluginDirectory: (workdir: string, type: PluginType) => string + getPluginsBasePath: () => string +} + +export class PluginCacheStore { + constructor(private readonly deps: PluginCacheStoreDeps) {} + + async listAvailableFilePlugins(type: 'agent' | 'command'): Promise { + const basePath = this.deps.getPluginsBasePath() + const directory = path.join(basePath, this.deps.getPluginDirectoryName(type)) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${directory}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(directory, category) + const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) + plugins.push(metadata) + } catch (error) { + logger.warn(`Failed to parse plugin: ${file.name}`, { + category, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + return plugins + } + + async listAvailableSkills(): Promise { + const basePath = this.deps.getPluginsBasePath() + const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill')) + const skills: PluginMetadata[] = [] + + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + try { + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + skills.push(metadata) + } catch (error) { + logger.warn(`Failed to parse skill folder: ${sourcePath}`, { + folderPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { + skillsPath, + error: error instanceof Error ? error.message : String(error) + }) + } + + return skills + } + + async readSourceContent(sourcePath: string): Promise { + const absolutePath = this.resolveSourcePath(sourcePath) + + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + try { + return await fs.promises.readFile(absolutePath, 'utf-8') + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + resolveSourcePath(sourcePath: string): string { + const normalized = path.normalize(sourcePath) + + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + const basePath = this.deps.getPluginsBasePath() + const absolutePath = path.join(basePath, normalized) + const resolvedPath = path.resolve(absolutePath) + + if (!resolvedPath.startsWith(path.resolve(basePath))) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path outside plugins directory', + path: sourcePath + } as PluginError + } + + return resolvedPath + } + + async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(sourceAbsolutePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: sourcePath + } as PluginError + } + } + + async validatePluginFile(filePath: string, maxFileSize: number): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + if (stats.size > maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: maxFileSize + } as PluginError + } + + const ext = path.extname(filePath).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + try { + const basePath = this.deps.getPluginsBasePath() + const relativeSourcePath = path.relative(basePath, filePath) + const segments = relativeSourcePath.split(path.sep) + const rootDir = segments[0] + const agentDir = this.deps.getPluginDirectoryName('agent') + const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command' + const category = path.basename(path.dirname(filePath)) + + await parsePluginMetadata(filePath, relativeSourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + async listInstalled(workdir: string): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + const cacheData = await this.readCacheFile(claudePath) + + if (cacheData) { + logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir }) + return cacheData.plugins + } + + logger.info('Cache read failed, rebuilding from filesystem', { workdir }) + return await this.rebuild(workdir) + } + + async upsert(workdir: string, plugin: InstalledPlugin): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const updatedPlugin: InstalledPlugin = { + ...plugin, + metadata: { + ...plugin.metadata, + installedAt: plugin.metadata.installedAt ?? Date.now() + } + } + + const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type) + if (index >= 0) { + plugins[index] = updatedPlugin + } else { + plugins.push(updatedPlugin) + } + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async remove(workdir: string, filename: string, type: PluginType): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type)) + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins: filtered + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async rebuild(workdir: string): Promise { + logger.info('Rebuilding plugin cache from filesystem', { workdir }) + + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(claudePath, fs.constants.R_OK) + } catch { + logger.warn('.claude directory not found, returning empty plugin list', { claudePath }) + return [] + } + + const plugins: InstalledPlugin[] = [] + + await Promise.all([ + this.collectFilePlugins(workdir, 'agent', plugins), + this.collectFilePlugins(workdir, 'command', plugins), + this.collectSkillPlugins(workdir, plugins) + ]) + + try { + const cacheData: CachedPluginsData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + await this.writeCacheFile(claudePath, cacheData) + logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir }) + } catch (error) { + logger.error('Failed to write cache file after rebuild', { + error: error instanceof Error ? error.message : String(error) + }) + } + + return plugins + } + + private async collectFilePlugins( + workdir: string, + type: Exclude, + plugins: InstalledPlugin[] + ): Promise { + const directory = this.deps.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch { + logger.debug(`${type} directory not found or not accessible`, { directory }) + return + } + + const files = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(directory, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type) + plugins.push({ filename: file.name, type, metadata }) + } catch (error) { + logger.warn(`Failed to parse ${type} plugin: ${file.name}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise { + const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill') + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(skillsPath, fs.constants.R_OK) + } catch { + logger.debug('Skills directory not found or not accessible', { skillsPath }) + return + } + + const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + plugins.push({ filename: metadata.filename, type: 'skill', metadata }) + } catch (error) { + logger.warn(`Failed to parse skill plugin: ${sourcePath}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async readCacheFile(claudePath: string): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + try { + const content = await fs.promises.readFile(cachePath, 'utf-8') + const data = JSON.parse(content) + return CachedPluginsDataSchema.parse(data) + } catch (err) { + logger.warn(`Failed to read cache file at ${cachePath}`, { + error: err instanceof Error ? err.message : String(err) + }) + return null + } + } + + private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + const tempPath = `${cachePath}.tmp` + + const content = JSON.stringify(data, null, 2) + await fs.promises.writeFile(tempPath, content, 'utf-8') + await fs.promises.rename(tempPath, cachePath) + } +} diff --git a/src/main/services/agents/plugins/PluginInstaller.ts b/src/main/services/agents/plugins/PluginInstaller.ts new file mode 100644 index 000000000..75acfc211 --- /dev/null +++ b/src/main/services/agents/plugins/PluginInstaller.ts @@ -0,0 +1,149 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import type { PluginError } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' + +const logger = loggerService.withContext('PluginInstaller') + +export class PluginInstaller { + async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { agentId, destPath }) + } catch (error) { + if (fileCopied) { + await this.safeUnlink(tempPath, 'temp file') + } + throw this.toPluginError('install', error) + } + } + + async uninstallFilePlugin( + agentId: string, + filename: string, + type: 'agent' | 'command', + filePath: string + ): Promise { + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { agentId, filename, type, filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall', error) + } + logger.warn('Plugin file already deleted', { agentId, filename, type, filePath }) + } + } + + async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + agentId, + filePath, + size: Buffer.byteLength(content, 'utf8') + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + + return crypto.createHash('sha256').update(content).digest('hex') + } + + async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const logContext = logger.withContext('installSkill') + let folderCopied = false + const tempPath = `${destPath}.tmp` + + try { + try { + await fs.promises.access(destPath) + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { agentId, destPath }) + } catch { + // No existing folder + } + + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { agentId, destPath }) + } catch (error) { + if (folderCopied) { + await this.safeRemoveDirectory(tempPath, 'temp folder') + } + throw this.toPluginError('install-skill', error) + } + } + + async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise { + const logContext = logger.withContext('uninstallSkill') + + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { agentId, folderName, skillPath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall-skill', error) + } + logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath }) + } + } + + private toPluginError(operation: string, error: unknown): PluginError { + return { + type: 'TRANSACTION_FAILED', + operation, + reason: error instanceof Error ? error.message : String(error) + } + } + + private async safeUnlink(targetPath: string, label: string): Promise { + try { + await fs.promises.unlink(targetPath) + logger.debug(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + private async safeRemoveDirectory(targetPath: string, label: string): Promise { + try { + await deleteDirectoryRecursive(targetPath) + logger.info(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } +} diff --git a/src/main/services/agents/plugins/PluginService.ts b/src/main/services/agents/plugins/PluginService.ts new file mode 100644 index 000000000..3076522a2 --- /dev/null +++ b/src/main/services/agents/plugins/PluginService.ts @@ -0,0 +1,614 @@ +import { loggerService } from '@logger' +import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + GetAgentResponse, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from '../services/AgentService' +import { PluginCacheStore } from './PluginCacheStore' +import { PluginInstaller } from './PluginInstaller' + +const logger = loggerService.withContext('PluginService') + +interface PluginServiceConfig { + maxFileSize: number // bytes + cacheTimeout: number // milliseconds +} + +/** + * PluginService manages agent and command plugins from resources directory. + * + * Features: + * - Singleton pattern for consistent state management + * - Caching of available plugins for performance + * - Security validation (path traversal, file size, extensions) + * - Transactional install/uninstall operations + * - Integration with AgentService for metadata persistence + */ +export class PluginService { + private static instance: PluginService | null = null + + private availablePluginsCache: ListAvailablePluginsResult | null = null + private cacheTimestamp = 0 + private config: PluginServiceConfig + private readonly cacheStore: PluginCacheStore + private readonly installer: PluginInstaller + private readonly agentService: AgentService + + private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + this.agentService = AgentService.getInstance() + this.cacheStore = new PluginCacheStore({ + allowedExtensions: this.ALLOWED_EXTENSIONS, + getPluginDirectoryName: this.getPluginDirectoryName.bind(this), + getClaudeBasePath: this.getClaudeBasePath.bind(this), + getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this), + getPluginsBasePath: this.getPluginsBasePath.bind(this) + }) + this.installer = new PluginInstaller() + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): PluginService { + if (!PluginService.instance) { + PluginService.instance = new PluginService(config) + } + return PluginService.instance + } + + /** + * List all available plugins from resources directory (with caching) + */ + async listAvailable(): Promise { + const now = Date.now() + + // Return cached data if still valid + if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { + logger.debug('Returning cached plugin list', { + cacheAge: now - this.cacheTimestamp + }) + return this.availablePluginsCache + } + + logger.info('Scanning available plugins') + + // Scan all plugin types + const [agents, commands, skills] = await Promise.all([ + this.cacheStore.listAvailableFilePlugins('agent'), + this.cacheStore.listAvailableFilePlugins('command'), + this.cacheStore.listAvailableSkills() + ]) + + const result: ListAvailablePluginsResult = { + agents, + commands, + skills, // NEW: include skills + total: agents.length + commands.length + skills.length + } + + // Update cache + this.availablePluginsCache = result + this.cacheTimestamp = now + + logger.info('Available plugins scanned', { + agentsCount: agents.length, + commandsCount: commands.length, + skillsCount: skills.length, + total: result.total + }) + + return result + } + + /** + * Install plugin with validation and transactional safety + */ + async install(options: InstallPluginOptions): Promise { + logger.info('Installing plugin', options) + + const context = await this.prepareInstallContext(options) + + if (options.type === 'skill') { + return await this.installSkillPlugin(options, context) + } + + return await this.installFilePlugin(options, context) + } + + private async prepareInstallContext(options: InstallPluginOptions): Promise<{ + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + }> { + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath) + + return { agent, workdir, sourceAbsolutePath } + } + + private async installSkillPlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath) + + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + await this.ensureClaudeDirectory(workdir, 'skill') + const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + metadata.filename = sanitizedFolderName + + await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFolderName, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: 'skill' + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFolderName, + type: 'skill', + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return metadataWithInstall + } + + private async installFilePlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + if (options.type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: options.type + } as PluginError + } + + const filePluginType: 'agent' | 'command' = options.type + + await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize) + + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType) + + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + metadata.filename = sanitizedFilename + + await this.ensureClaudeDirectory(workdir, filePluginType) + const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename) + + await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFilename, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: filePluginType + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFilename, + type: filePluginType, + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: filePluginType + }) + + return metadataWithInstall + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + if (options.type === 'skill') { + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath) + await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill') + this.removeAgentPlugin(agent, sanitizedFolderName, 'skill') + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + const sanitizedFilename = this.sanitizeFilename(options.filename) + const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename) + + await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath) + await this.cacheStore.remove(workdir, sanitizedFilename, options.type) + this.removeAgentPlugin(agent, sanitizedFilename, options.type) + + logger.info('Plugin uninstalled successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + } + + /** + * List installed plugins for an agent (from database + filesystem validation) + */ + async listInstalled(agentId: string): Promise { + logger.debug('Listing installed plugins', { agentId }) + + const agent = await this.getAgentOrThrow(agentId) + + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + const plugins = await this.listInstalledFromCache(workdir) + + logger.debug('Listed installed plugins from cache', { + agentId, + count: plugins.length + }) + + return plugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + // ============================================================================ + // Cache File Management (for installed plugins) + // ============================================================================ + + /** + * Read cache file from .claude/plugins.json + * Returns null if cache doesn't exist or is invalid + */ + + /** + * List installed plugins from cache file + * Falls back to filesystem scan if cache is missing or corrupt + */ + async listInstalledFromCache(workdir: string): Promise { + logger.debug('Listing installed plugins from cache', { workdir }) + return await this.cacheStore.listInstalled(workdir) + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + const content = await this.cacheStore.readSourceContent(sourcePath) + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } + + /** + * Write plugin content to installed plugin (in agent's .claude directory) + * Note: Only works for file-based plugins (agents/commands), not skills + */ + async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { + logger.info('Writing plugin content', { agentId, filename, type }) + + const agent = await this.getAgentOrThrow(agentId) + const workdir = this.getWorkdirOrThrow(agent, agentId) + + await this.validateWorkdir(agent, workdir) + + // Check if plugin is installed + let installedPlugins = agent.installed_plugins ?? [] + if (installedPlugins.length === 0) { + installedPlugins = await this.cacheStore.listInstalled(workdir) + agent.installed_plugins = installedPlugins + } + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + if (type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: type + } as PluginError + } + + const filePluginType = type as 'agent' | 'command' + const filePath = this.getClaudePluginPath(workdir, filePluginType, filename) + const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content) + + const updatedMetadata: PluginMetadata = { + ...installedPlugin.metadata, + contentHash: newContentHash, + size: Buffer.byteLength(content, 'utf8'), + updatedAt: Date.now(), + filename, + type: filePluginType + } + const updatedPlugin: InstalledPlugin = { + filename, + type: filePluginType, + metadata: updatedMetadata + } + + await this.cacheStore.upsert(workdir, updatedPlugin) + this.upsertAgentPlugin(agent, updatedPlugin) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type: filePluginType, + newContentHash + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Resolve plugin type to directory name under .claude + */ + private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' { + if (type === 'agent') { + return 'agents' + } + if (type === 'command') { + return 'commands' + } + return 'skills' + } + + /** + * Get the base .claude directory for a workdir + */ + private getClaudeBasePath(workdir: string): string { + return path.join(workdir, '.claude') + } + + /** + * Get the directory for a specific plugin type inside .claude + */ + private getClaudePluginDirectory(workdir: string, type: PluginType): string { + return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type)) + } + + /** + * Get the absolute path for a plugin file/folder inside .claude + */ + private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string { + return path.join(this.getClaudePluginDirectory(workdir, type), filename) + } + + /** + * Get absolute path to plugins directory (handles packaged vs dev) + */ + private getPluginsBasePath(): string { + // Use the utility function which handles both dev and production correctly + if (app.isPackaged) { + return path.join(process.resourcesPath, 'claude-code-plugins') + } + return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') + } + + /** + * Validate source path to prevent path traversal attacks + */ + private async getAgentOrThrow(agentId: string): Promise { + const agent = await this.agentService.getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + return agent + } + + private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + return workdir + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise { + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId: agent.id, + message: 'Workdir not in agent accessible paths' + } as PluginError + } + + // Verify workdir exists and is accessible + try { + await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) + } catch (error) { + throw { + type: 'WORKDIR_NOT_FOUND', + workdir, + message: 'Workdir does not exist or is not accessible' + } as PluginError + } + } + + private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void { + const existing = agent.installed_plugins ?? [] + const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type)) + agent.installed_plugins = [...filtered, plugin] + } + + private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void { + if (!agent.installed_plugins) { + agent.installed_plugins = [] + return + } + agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type)) + } + + /** + * Sanitize filename to remove unsafe characters (for agents/commands) + */ + private sanitizeFilename(filename: string): string { + // Remove path separators + let sanitized = filename.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore, dot) + sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Ensure .md extension + if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { + sanitized += '.md' + } + + return sanitized + } + + /** + * Sanitize folder name for skills (different rules than file names) + * NO dots allowed to avoid confusion with file extensions + */ + private sanitizeFolderName(folderName: string): string { + // Remove path separators + let sanitized = folderName.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore) + // NOTE: No dots allowed to avoid confusion with file extensions + sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Validate no extension was provided + if (folderName.includes('.')) { + logger.warn('Skill folder name contained dots, sanitized', { + original: folderName, + sanitized + }) + } + + return sanitized + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const typeDir = this.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.mkdir(typeDir, { recursive: true }) + logger.debug('Ensured directory exists', { typeDir }) + } catch (error) { + logger.error('Failed to create directory', { + typeDir, + error: error instanceof Error ? error.message : String(error) + }) + throw { + type: 'PERMISSION_DENIED', + path: typeDir + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 7dda4e4f1..07ed89a0f 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { loggerService } from '@logger' +import { pluginService } from '@main/services/agents/plugins/PluginService' import { getDataPath } from '@main/utils' import type { AgentEntity, @@ -17,6 +19,8 @@ import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' import type { AgentModelField } from '../errors' +const logger = loggerService.withContext('AgentService') + export class AgentService extends BaseService { private static instance: AgentService | null = null private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model'] @@ -92,6 +96,24 @@ export class AgentService extends BaseService { const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse agent.tools = await this.listMcpTools(agent.type, agent.mcps) + + // Load installed_plugins from cache file instead of database + const workdir = agent.accessible_paths?.[0] + if (workdir) { + try { + agent.installed_plugins = await pluginService.listInstalledFromCache(workdir) + } catch (error) { + // Log error but don't fail the request + logger.warn(`Failed to load installed plugins for agent ${id}`, { + workdir, + error: error instanceof Error ? error.message : String(error) + }) + agent.installed_plugins = [] + } + } else { + agent.installed_plugins = [] + } + return agent } diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index beb1a6a9f..c9a8c677c 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -106,7 +106,10 @@ class ClaudeCodeService implements AgentServiceInterface { ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey, ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost, ANTHROPIC_MODEL: modelInfo.modelId, - ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId, + // TODO: support set small model in UI + ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId, ELECTRON_RUN_AS_NODE: '1', ELECTRON_NO_ATTACH_CONSOLE: '1' } diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 5fb45213e..5905ed643 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -73,6 +73,15 @@ const emptyUsage: LanguageModelUsage = { */ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` +/** + * Filters out command-* tags from text content to prevent internal command + * messages from appearing in the user-facing UI. + * Removes tags like ... and ... + */ +const filterCommandTags = (text: string): string => { + return text.replace(/]+>.*?<\/command-[^>]+>/gs, '').trim() +} + /** * Extracts provider metadata from the raw Claude message so we can surface it * on every emitted stream part for observability and debugging purposes. @@ -270,12 +279,17 @@ function handleUserMessage( const chunks: AgentStreamPart[] = [] const providerMetadata = sdkMessageToProviderMetadata(message) const content = message.message.content - + const isSynthetic = message.isSynthetic ?? false if (typeof content === 'string') { if (!content) { return chunks } + const filteredContent = filterCommandTags(content) + if (!filteredContent) { + return chunks + } + const id = message.uuid?.toString() || generateMessageId() chunks.push({ type: 'text-start', @@ -285,7 +299,7 @@ function handleUserMessage( chunks.push({ type: 'text-delta', id, - text: content, + text: filteredContent, providerMetadata }) chunks.push({ @@ -323,24 +337,30 @@ function handleUserMessage( providerExecuted: true }) } - } else if (block.type === 'text') { - const id = message.uuid?.toString() || generateMessageId() - chunks.push({ - type: 'text-start', - id, - providerMetadata - }) - chunks.push({ - type: 'text-delta', - id, - text: (block as { text: string }).text, - providerMetadata - }) - chunks.push({ - type: 'text-end', - id, - providerMetadata - }) + } else if (block.type === 'text' && !isSynthetic) { + const rawText = (block as { text: string }).text + const filteredText = filterCommandTags(rawText) + + // Only push text chunks if there's content after filtering + if (filteredText) { + const id = message.uuid?.toString() || generateMessageId() + chunks.push({ + type: 'text-start', + id, + providerMetadata + }) + chunks.push({ + type: 'text-delta', + id, + text: filteredText, + providerMetadata + }) + chunks.push({ + type: 'text-end', + id, + providerMetadata + }) + } } else { logger.warn('Unhandled user content block', { type: (block as any).type }) } diff --git a/src/main/services/urlschema/mcp-install.ts b/src/main/services/urlschema/mcp-install.ts index a93ceabc0..2d783255e 100644 --- a/src/main/services/urlschema/mcp-install.ts +++ b/src/main/services/urlschema/mcp-install.ts @@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl') function installMCPServer(server: MCPServer) { const mainWindow = windowService.getMainWindow() + const now = Date.now() - if (!server.id) { - server.id = nanoid() + const payload: MCPServer = { + ...server, + id: server.id ?? nanoid(), + installSource: 'protocol', + isTrusted: false, + isActive: false, + trustedAt: undefined, + installedAt: server.installedAt ?? now } if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server) + mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c19f8c96..1545b6646 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -567,7 +567,16 @@ const api = { getStatus: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus), start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), - stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop), + onReady: (callback: () => void): (() => void) => { + const listener = () => { + callback() + } + ipcRenderer.on(IpcChannel.ApiServer_Ready, listener) + return () => { + ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener) + } + } }, claudeCodePlugin: { listAvailable: (): Promise> => @@ -583,6 +592,13 @@ const api = { ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath), writeContent: (options: WritePluginContentOptions): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) + }, + webSocket: { + start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), + stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), + status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), + sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), + getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) } } diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx new file mode 100644 index 000000000..d826ddd01 --- /dev/null +++ b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx @@ -0,0 +1,591 @@ +import { Button } from '@heroui/button' +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal' +import { Progress } from '@heroui/progress' +import { Spinner } from '@heroui/spinner' +import { loggerService } from '@logger' +import { SettingHelpText, SettingRow } from '@renderer/pages/settings' +import { QRCodeSVG } from 'qrcode.react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +const logger = loggerService.withContext('ExportToPhoneLanPopup') + +interface Props { + resolve: (data: any) => void +} + +type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' +type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' + +const LoadingQRCode: React.FC = () => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.generating_qr')} + +
+ ) +} + +const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.scan_qr')} + +
+ ) +} + +const ConnectingAnimation: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ + + {t('settings.data.export_to_phone.lan.status.connecting')} + +
+
+ ) +} + +const ConnectedDisplay: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ 📱 + + {t('settings.data.export_to_phone.lan.connected')} + +
+
+ ) +} + +const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { + const { t } = useTranslation() + return ( +
+ ⚠️ + + {t('settings.data.export_to_phone.lan.connection_failed')} + + {error && {error}} +
+ ) +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [isOpen, setIsOpen] = useState(true) + const [connectionPhase, setConnectionPhase] = useState('initializing') + const [transferPhase, setTransferPhase] = useState('idle') + const [qrCodeValue, setQrCodeValue] = useState('') + const [selectedFolderPath, setSelectedFolderPath] = useState(null) + const [sendProgress, setSendProgress] = useState(0) + const [error, setError] = useState(null) + const [showCloseConfirm, setShowCloseConfirm] = useState(false) + const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) + + const { t } = useTranslation() + + // 派生状态 + const isConnected = connectionPhase === 'connected' + const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' + const isSending = transferPhase === 'preparing' || transferPhase === 'sending' + + // 状态文本映射 + const connectionStatusText = useMemo(() => { + const statusMap = { + initializing: t('settings.data.export_to_phone.lan.status.initializing'), + waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), + connecting: t('settings.data.export_to_phone.lan.status.connecting'), + connected: t('settings.data.export_to_phone.lan.status.connected'), + disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[connectionPhase] + }, [connectionPhase, t]) + + const transferStatusText = useMemo(() => { + const statusMap = { + idle: '', + preparing: t('settings.data.export_to_phone.lan.status.preparing'), + sending: t('settings.data.export_to_phone.lan.status.sending'), + completed: t('settings.data.export_to_phone.lan.status.completed'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[transferPhase] + }, [transferPhase, t]) + + // 状态样式映射 + const connectionStatusStyles = useMemo(() => { + const styleMap = { + initializing: { + bg: 'var(--color-background-mute)', + border: 'var(--color-border-mute)' + }, + waiting_qr_scan: { + bg: 'var(--color-primary-mute)', + border: 'var(--color-primary-soft)' + }, + connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, + connected: { + bg: 'var(--color-status-success)', + border: 'var(--color-status-success)' + }, + disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, + error: { bg: 'var(--color-error)', border: 'var(--color-error)' } + } + return styleMap[connectionPhase] + }, [connectionPhase]) + + const initWebSocket = useCallback(async () => { + try { + setConnectionPhase('initializing') + await window.api.webSocket.start() + const { port, ip } = await window.api.webSocket.status() + + if (ip && port) { + const candidates = await window.api.webSocket.getAllCandidates() + const connectionInfo = { + type: 'cherry-studio-app', + candidates, + selectedHost: ip, + port, + timestamp: Date.now() + } + setQrCodeValue(JSON.stringify(connectionInfo)) + setConnectionPhase('waiting_qr_scan') + logger.info(`QR code generated: ${ip}:${port} with ${candidates.length} IP candidates`) + } else { + setError(t('settings.data.export_to_phone.lan.error.no_ip')) + setConnectionPhase('error') + } + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setConnectionPhase('error') + logger.error('Failed to initialize WebSocket:', error as Error) + } + }, [t]) + + const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { + logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) + if (data.connected) { + setConnectionPhase('connected') + setError(null) + } else { + setConnectionPhase('disconnected') + } + }, []) + + const handleMessageReceived = useCallback((_event: any, data: any) => { + logger.info(`Received message from mobile: ${JSON.stringify(data)}`) + }, []) + + const handleSendProgress = useCallback( + (_event: any, data: { progress: number }) => { + const progress = data.progress + setSendProgress(progress) + + if (transferPhase === 'preparing' && progress > 0) { + setTransferPhase('sending') + } + + if (progress >= 100) { + setTransferPhase('completed') + // 启动 3 秒倒计时自动关闭 + setAutoCloseCountdown(3) + } + }, + [transferPhase] + ) + + const handleSelectZip = useCallback(async () => { + const result = await window.api.file.select() + if (result) { + setSelectedFolderPath(result[0].path) + } + }, []) + + const handleSendZip = useCallback(async () => { + if (!selectedFolderPath) { + setError(t('settings.data.export_to_phone.lan.error.no_file')) + return + } + + setTransferPhase('preparing') + setError(null) + setSendProgress(0) + + try { + logger.info(`Starting file transfer: ${selectedFolderPath}`) + await window.api.webSocket.sendFile(selectedFolderPath) + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setTransferPhase('error') + logger.error('Failed to send file:', error as Error) + } + }, [selectedFolderPath, t]) + + // 尝试关闭弹窗 - 如果正在传输则显示确认 + const handleCancel = useCallback(() => { + if (isSending) { + setShowCloseConfirm(true) + } else { + setIsOpen(false) + } + }, [isSending]) + + // 确认强制关闭 + const handleForceClose = useCallback(() => { + logger.info('Force closing popup during transfer') + setIsOpen(false) + }, []) + + // 取消关闭确认 + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false) + }, []) + + // 清理并关闭 + const handleClose = useCallback(async () => { + try { + // 主动断开 WebSocket 连接 + if (isConnected || connectionPhase !== 'disconnected') { + logger.info('Closing popup, stopping WebSocket') + await window.api.webSocket.stop() + } + } catch (error) { + logger.error('Failed to stop WebSocket on close:', error as Error) + } + resolve({}) + }, [resolve, isConnected, connectionPhase]) + + useEffect(() => { + initWebSocket() + + const removeClientConnectedListener = window.electron.ipcRenderer.on( + 'websocket-client-connected', + handleClientConnected + ) + const removeMessageReceivedListener = window.electron.ipcRenderer.on( + 'websocket-message-received', + handleMessageReceived + ) + const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) + + return () => { + removeClientConnectedListener() + removeMessageReceivedListener() + removeSendProgressListener() + window.api.webSocket.stop() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 自动关闭倒计时 + useEffect(() => { + if (autoCloseCountdown === null) return + + if (autoCloseCountdown <= 0) { + logger.debug('Auto-closing popup after transfer completion') + setIsOpen(false) + return + } + + const timer = setTimeout(() => { + setAutoCloseCountdown(autoCloseCountdown - 1) + }, 1000) + + return () => clearTimeout(timer) + }, [autoCloseCountdown]) + + // 状态指示器组件 + const StatusIndicator = useCallback( + () => ( +
+ {connectionStatusText} +
+ ), + [connectionStatusStyles, connectionStatusText] + ) + + // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 + const QRCodeDisplay = useCallback(() => { + switch (connectionPhase) { + case 'waiting_qr_scan': + case 'disconnected': + return + case 'initializing': + return + case 'connecting': + return + case 'connected': + return + case 'error': + return + default: + return null + } + }, [connectionPhase, qrCodeValue, error]) + + // 传输进度组件 + const TransferProgress = useCallback(() => { + if (!isSending && transferPhase !== 'completed') return null + + return ( +
+
+
+ + {t('settings.data.export_to_phone.lan.transfer_progress')} + + + {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} + +
+ + +
+
+ ) + }, [isSending, transferPhase, sendProgress, t]) + + const AutoCloseCountdown = useCallback(() => { + if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null + + return ( +
+ {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} +
+ ) + }, [transferPhase, autoCloseCountdown, t]) + + // 错误显示组件 + const ErrorDisplay = useCallback(() => { + if (!error || transferPhase !== 'error') return null + + return ( +
+ ❌ {error} +
+ ) + }, [error, transferPhase]) + + return ( + { + if (!open) { + handleCancel() + } + }} + isDismissable={false} + isKeyboardDismissDisabled={false} + placement="center" + onClose={handleClose}> + + {() => ( + <> + {t('settings.data.export_to_phone.lan.title')} + + + + + + +
{t('settings.data.export_to_phone.lan.content')}
+
+ + + + + + +
+ + +
+
+ + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + + + + + +
+ + {showCloseConfirm && ( + +
+
+ ⚠️ + + {t('settings.data.export_to_phone.lan.confirm_close_title')} + +
+ + {t('settings.data.export_to_phone.lan.confirm_close_message')} + +
+ + +
+
+
+ )} + + )} +
+
+ ) +} + +const TopViewKey = 'ExportToPhoneLanPopup' + +export default class ExportToPhoneLanPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 772cdd84a..547ea885b 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -68,6 +68,7 @@ type Props = { agent?: AgentWithTools isOpen: boolean onClose: () => void + afterSubmit?: (a: AgentEntity) => void } /** @@ -79,7 +80,7 @@ type Props = { * @param onClose - Optional callback when modal closes. From useDisclosure. * @returns Modal component for agent creation/editing */ -export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _onClose }) => { +export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => { const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) const { t } = useTranslation() const loadingRef = useRef(false) @@ -302,8 +303,13 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ configuration: form.configuration ? { ...form.configuration } : undefined } satisfies UpdateAgentForm - updateAgent(updatePayload) - logger.debug('Updated agent', updatePayload) + const result = await updateAgent(updatePayload) + if (result) { + logger.debug('Updated agent', result) + afterSubmit?.(result) + } else { + logger.error('Update failed.') + } } else { const newAgent = { type: form.type, @@ -316,12 +322,13 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ configuration: form.configuration ? { ...form.configuration } : undefined } satisfies AddAgentForm const result = await addAgent(newAgent) + if (!result.success) { loadingRef.current = false throw result.error } + afterSubmit?.(result.data) } - loadingRef.current = false // setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) @@ -330,16 +337,17 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ [ form.type, form.model, + form.accessible_paths, form.name, form.description, form.instructions, - form.accessible_paths, form.allowed_tools, form.configuration, agent, onClose, t, updateAgent, + afterSubmit, addAgent ] ) diff --git a/src/renderer/src/hooks/agents/types.ts b/src/renderer/src/hooks/agents/types.ts deleted file mode 100644 index 9cf5769f5..000000000 --- a/src/renderer/src/hooks/agents/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type UpdateAgentBaseOptions = { - /** Whether to show success toast after updating. Defaults to true. */ - showSuccessToast?: boolean -} diff --git a/src/renderer/src/hooks/agents/useAgents.ts b/src/renderer/src/hooks/agents/useAgents.ts index 48d86abd0..7b0081126 100644 --- a/src/renderer/src/hooks/agents/useAgents.ts +++ b/src/renderer/src/hooks/agents/useAgents.ts @@ -25,6 +25,10 @@ export const useAgents = () => { const client = useAgentClient() const key = client.agentPaths.base const { apiServerConfig, apiServerRunning } = useApiServer() + + // Disable SWR fetching when server is not running by setting key to null + const swrKey = apiServerRunning ? key : null + const fetcher = useCallback(async () => { // API server will start on startup if enabled OR there are agents if (!apiServerConfig.enabled && !apiServerRunning) { @@ -37,7 +41,7 @@ export const useAgents = () => { // NOTE: We only use the array for now. useUpdateAgent depends on this behavior. return result.data }, [apiServerConfig.enabled, apiServerRunning, client, t]) - const { data, error, isLoading, mutate } = useSWR(key, fetcher) + const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher) const { chat } = useRuntime() const { activeAgentId } = chat const dispatch = useAppDispatch() diff --git a/src/renderer/src/hooks/agents/useUpdateAgent.ts b/src/renderer/src/hooks/agents/useUpdateAgent.ts index 6cc697b7e..abbe1ed46 100644 --- a/src/renderer/src/hooks/agents/useUpdateAgent.ts +++ b/src/renderer/src/hooks/agents/useUpdateAgent.ts @@ -1,10 +1,10 @@ -import type { ListAgentsResponse, UpdateAgentForm } from '@renderer/types' +import type { AgentEntity, ListAgentsResponse, UpdateAgentForm } from '@renderer/types' +import type { UpdateAgentBaseOptions, UpdateAgentFunction } from '@renderer/types/agent' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' -import type { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' export const useUpdateAgent = () => { @@ -12,8 +12,8 @@ export const useUpdateAgent = () => { const client = useAgentClient() const listKey = client.agentPaths.base - const updateAgent = useCallback( - async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => { + const updateAgent: UpdateAgentFunction = useCallback( + async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions): Promise => { try { const itemKey = client.agentPaths.withId(form.id) // may change to optimistic update @@ -23,8 +23,10 @@ export const useUpdateAgent = () => { if (options?.showSuccessToast ?? true) { window.toast.success(t('common.update_success')) } + return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed'))) + return undefined } }, [client, listKey, t] diff --git a/src/renderer/src/hooks/agents/useUpdateSession.ts b/src/renderer/src/hooks/agents/useUpdateSession.ts index 233fb717e..759b7d25f 100644 --- a/src/renderer/src/hooks/agents/useUpdateSession.ts +++ b/src/renderer/src/hooks/agents/useUpdateSession.ts @@ -1,18 +1,18 @@ -import type { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types' +import type { AgentSessionEntity, ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types' +import type { UpdateAgentBaseOptions, UpdateAgentSessionFunction } from '@renderer/types/agent' import { getErrorMessage } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' -import type { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' export const useUpdateSession = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const updateSession = useCallback( - async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => { + const updateSession: UpdateAgentSessionFunction = useCallback( + async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions): Promise => { if (!agentId) return const paths = client.getSessionPaths(agentId) const listKey = paths.base @@ -29,8 +29,10 @@ export const useUpdateSession = (agentId: string | null) => { if (options?.showSuccessToast ?? true) { window.toast.success(t('common.update_success')) } + return result } catch (error) { window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) }) + return undefined } }, [agentId, client, t] diff --git a/src/renderer/src/hooks/useApiServer.ts b/src/renderer/src/hooks/useApiServer.ts index ae418f6cc..38f6fa64d 100644 --- a/src/renderer/src/hooks/useApiServer.ts +++ b/src/renderer/src/hooks/useApiServer.ts @@ -14,8 +14,8 @@ export const useApiServer = () => { const apiServerConfig = useAppSelector((state) => state.settings.apiServer) const dispatch = useAppDispatch() - // Optimistic initial state. - const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled) + // Initial state - no longer optimistic, wait for actual status + const [apiServerRunning, setApiServerRunning] = useState(false) const [apiServerLoading, setApiServerLoading] = useState(true) const setApiServerEnabled = useCallback( @@ -99,6 +99,16 @@ export const useApiServer = () => { checkApiServerStatus() }, [checkApiServerStatus]) + // Listen for API server ready event + useEffect(() => { + const cleanup = window.api.apiServer.onReady(() => { + logger.info('API server ready event received, checking status') + checkApiServerStatus() + }) + + return cleanup + }, [checkApiServerStatus]) + return { apiServerConfig, apiServerRunning, diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index e829b995a..e66eff45a 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -80,17 +80,34 @@ export function useAppInit() { useEffect(() => { savedAvatar?.value && cacheService.set('avatar', savedAvatar.value) - }, [savedAvatar, dispatch]) + }, [savedAvatar]) useEffect(() => { + const checkForUpdates = async () => { + const { isPackaged } = await window.api.getAppInfo() + + if (!isPackaged || !autoCheckUpdate) { + return + } + + const { updateInfo } = await window.api.checkForUpdate() + updateAppUpdateState({ info: updateInfo }) + } + + // Initial check with delay runAsyncFunction(async () => { const { isPackaged } = await window.api.getAppInfo() if (isPackaged && autoCheckUpdate) { await delay(2) - const { updateInfo } = await window.api.checkForUpdate() - updateAppUpdateState({ info: updateInfo }) + await checkForUpdates() } }) + + // Set up 4-hour interval check + const FOUR_HOURS = 4 * 60 * 60 * 1000 + const intervalId = setInterval(checkForUpdates, FOUR_HOURS) + + return () => clearInterval(intervalId) }, [autoCheckUpdate, updateAppUpdateState]) useEffect(() => { @@ -135,7 +152,7 @@ export function useAppInit() { cacheService.set('filesPath', info.filesPath) cacheService.set('resourcesPath', info.resourcesPath) }) - }, [dispatch]) + }, []) useEffect(() => { KnowledgeQueue.checkAllBases() diff --git a/src/renderer/src/hooks/useMCPServerTrust.tsx b/src/renderer/src/hooks/useMCPServerTrust.tsx new file mode 100644 index 000000000..ee3fefba6 --- /dev/null +++ b/src/renderer/src/hooks/useMCPServerTrust.tsx @@ -0,0 +1,57 @@ +import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning' +import { + ensureServerTrusted as ensureServerTrustedCore, + getCommandPreview +} from '@renderer/pages/settings/MCPSettings/utils' +import type { MCPServer } from '@renderer/types' +import { modalConfirm } from '@renderer/utils' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import { useMCPServers } from './useMCPServers' + +/** + * Hook for handling MCP server trust verification + * Binds UI (modal dialog) to the core trust verification logic + */ +export const useMCPServerTrust = () => { + const { updateMCPServer } = useMCPServers() + const { t } = useTranslation() + + /** + * Request user confirmation to trust a server + * Shows a warning modal with server command preview + */ + const requestConfirm = useCallback( + async (server: MCPServer): Promise => { + const commandPreview = getCommandPreview(server) + return modalConfirm({ + title: t('settings.mcp.protocolInstallWarning.title'), + content: ( + + ), + okText: t('settings.mcp.protocolInstallWarning.run'), + cancelText: t('common.cancel'), + okButtonProps: { danger: true } + }) + }, + [t] + ) + + /** + * Ensures a server is trusted before proceeding + * Combines core logic with UI confirmation + */ + const ensureServerTrusted = useCallback( + async (server: MCPServer): Promise => { + return ensureServerTrustedCore(server, requestConfirm, updateMCPServer) + }, + [requestConfirm, updateMCPServer] + ) + + return { ensureServerTrusted } +} diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index 740326bec..b2320b57e 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -11,12 +11,12 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import type { Assistant, Topic } from '@renderer/types' import { findMainTextBlocks } from '@renderer/utils/messageUtils/find' import { find, isEmpty } from 'lodash' -import { useEffect, useState } from 'react' +import { type Dispatch, type SetStateAction, useEffect, useState } from 'react' import { useAssistant } from './useAssistant' let _activeTopic: Topic -let _setActiveTopic: (topic: Topic) => void +let _setActiveTopic: Dispatch> // const logger = loggerService.withContext('useTopic') diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a416aeb00..9eaab8407 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1047,6 +1047,7 @@ "clear": "Clear", "close": "Close", "collapse": "Collapse", + "completed": "Completed", "confirm": "Confirm", "copied": "Copied", "copy": "Copy", @@ -3038,6 +3039,46 @@ "title": "Export Menu Settings", "yuque": "Export to Yuque" }, + "export_to_phone": { + "confirm": { + "button": "Select backup file" + }, + "content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "Confirm Close", + "connected": "Connected", + "connection_failed": "Connection failed", + "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "Initialization failed", + "no_file": "No file selected", + "no_ip": "Unable to get IP address", + "send_failed": "Failed to send file" + }, + "force_close": "Force Close", + "generating_qr": "Generating QR code...", + "noZipSelected": "No compressed file selected", + "scan_qr": "Please scan QR code with your phone", + "selectZip": "Select a compressed file", + "sendZip": "Begin data recovery", + "status": { + "completed": "Transfer completed", + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected", + "error": "Connection error", + "initializing": "Initializing connection...", + "preparing": "Preparing transfer...", + "sending": "Transferring {{progress}}%", + "waiting_qr_scan": "Please scan QR code to connect" + }, + "title": "LAN transmission", + "transfer_progress": "Transfer progress" + }, + "title": "Export to phone" + }, "hour_interval_one": "{{count}} hour", "hour_interval_other": "{{count}} hours", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "No prompts available", "requiredField": "Required Field" }, + "protocolInstallWarning": { + "command": "Startup command", + "message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.", + "run": "Run", + "title": "Run external MCP?" + }, "provider": "Provider", "providerPlaceholder": "Provider name", "providerUrl": "Provider URL", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b45f4d772..c3f486c0b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "关闭", "collapse": "折叠", + "completed": "完成", "confirm": "确认", "copied": "已复制", "copy": "复制", @@ -3038,6 +3039,46 @@ "title": "导出菜单设置", "yuque": "导出到语雀" }, + "export_to_phone": { + "confirm": { + "button": "选择备份文件" + }, + "content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "{{seconds}} 秒后自动关闭...", + "confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?", + "confirm_close_title": "确认关闭", + "connected": "连接成功", + "connection_failed": "连接失败", + "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "初始化失败", + "no_file": "未选择文件", + "no_ip": "无法获取 IP 地址", + "send_failed": "发送文件失败" + }, + "force_close": "强制关闭", + "generating_qr": "正在生成二维码...", + "noZipSelected": "未选择压缩文件", + "scan_qr": "请使用手机扫码连接", + "selectZip": "选择压缩文件", + "sendZip": "开始恢复数据", + "status": { + "completed": "传输完成", + "connected": "连接成功", + "connecting": "正在连接中...", + "disconnected": "连接已断开", + "error": "连接出错", + "initializing": "正在初始化连接...", + "preparing": "准备传输中...", + "sending": "传输中 {{progress}}%", + "waiting_qr_scan": "请扫描二维码连接" + }, + "title": "局域网传输", + "transfer_progress": "传输进度" + }, + "title": "导出至手机" + }, "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "无可用提示", "requiredField": "必填字段" }, + "protocolInstallWarning": { + "command": "启动命令", + "message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。", + "run": "运行", + "title": "运行外部 MCP?" + }, "provider": "提供者", "providerPlaceholder": "提供者名称", "providerUrl": "提供者网址", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9179f1804..58174e694 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "關閉", "collapse": "折疊", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "已複製", "copy": "複製", @@ -3038,6 +3039,46 @@ "title": "匯出選單設定", "yuque": "匯出到語雀" }, + "export_to_phone": { + "confirm": { + "button": "選擇備份檔案" + }, + "content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "未選取壓縮檔案", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "選擇壓縮檔案", + "sendZip": "開始恢復資料", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "區域網路傳輸", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "匯出手機" + }, "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "無可用提示", "requiredField": "必填欄位" }, + "protocolInstallWarning": { + "command": "啟動命令", + "message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。", + "run": "執行", + "title": "執行外部 MCP?" + }, "provider": "提供者", "providerPlaceholder": "提供者名稱", "providerUrl": "提供者網址", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 169114486..49a68809e 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -1047,6 +1047,7 @@ "clear": "Löschen", "close": "Schließen", "collapse": "Einklappen", + "completed": "[to be translated]:Completed", "confirm": "Bestätigen", "copied": "Kopiert", "copy": "Kopieren", @@ -3038,6 +3039,46 @@ "title": "Export-Menü-Einstellungen", "yuque": "Nach Yuque exportieren" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:Select backup file" + }, + "content": "[to be translated]:Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:No compressed file selected", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:Select a compressed file", + "sendZip": "[to be translated]:Begin data recovery", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:LAN transmission", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:Export to phone" + }, "hour_interval_one": "{{count}} Stunde", "hour_interval_other": "{{count}} Stunden", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "Keine Prompts verfügbar", "requiredField": "Pflichtfeld" }, + "protocolInstallWarning": { + "command": "Startbefehl", + "message": "Dieses MCP wurde über ein Protokoll aus einer externen Quelle installiert. Das Ausführen unbekannter Tools kann Ihren Computer schädigen.", + "run": "Laufen", + "title": "Externes MCP ausführen?" + }, "provider": "Anbieter", "providerPlaceholder": "Anbietername", "providerUrl": "Anbieter-Website", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 88fade4b2..5a38b8051 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1047,6 +1047,7 @@ "clear": "Καθαρισμός", "close": "Κλείσιμο", "collapse": "Σύμπτυξη", + "completed": "[to be translated]:Completed", "confirm": "Επιβεβαίωση", "copied": "Αντιγράφηκε", "copy": "Αντιγραφή", @@ -3038,6 +3039,46 @@ "title": "Εξαγωγή ρυθμίσεων μενού", "yuque": "Εξαγωγή στο Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} ώρα", "hour_interval_other": "{{count}} ώρες", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις", "requiredField": "Υποχρεωτικό πεδίο" }, + "protocolInstallWarning": { + "command": "Εντολή εκκίνησης", + "message": "Αυτό το MCP εγκαταστάθηκε από εξωτερική πηγή μέσω πρωτοκόλλου. Η εκτέλεση άγνωστων εργαλείων ενδέχεται να βλάψει τον υπολογιστή σας.", + "run": "Τρέξε", + "title": "Εκτέλεση εξωτερικού MCP;" + }, "provider": "Πάροχος", "providerPlaceholder": "Όνομα παρόχου", "providerUrl": "URL Παρόχου", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 9e5a5d75e..b04258151 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1047,6 +1047,7 @@ "clear": "Limpiar", "close": "Cerrar", "collapse": "Colapsar", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar configuración del menú", "yuque": "Exportar a Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "No hay indicaciones disponibles", "requiredField": "Campo obligatorio" }, + "protocolInstallWarning": { + "command": "Comando de inicio", + "message": "Este MCP fue instalado desde una fuente externa a través del protocolo. Ejecutar herramientas desconocidas puede dañar tu computadora.", + "run": "Correr", + "title": "¿Ejecutar MCP externo?" + }, "provider": "Proveedor", "providerPlaceholder": "Nombre del proveedor", "providerUrl": "URL del proveedor", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 35f48bd35..b9c68d9a8 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1047,6 +1047,7 @@ "clear": "Effacer", "close": "Fermer", "collapse": "Réduire", + "completed": "[to be translated]:Completed", "confirm": "Confirmer", "copied": "Copié", "copy": "Copier", @@ -3038,6 +3039,46 @@ "title": "Exporter les paramètres du menu", "yuque": "Exporter vers Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} heure", "hour_interval_other": "{{count}} heures", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "Aucune invite disponible", "requiredField": "Champ obligatoire" }, + "protocolInstallWarning": { + "command": "Commande de démarrage", + "message": "Ce MCP a été installé depuis une source externe via le protocole. L'exécution d'outils inconnus peut endommager votre ordinateur.", + "run": "Courir", + "title": "Exécuter un MCP externe ?" + }, "provider": "Поставщик", "providerPlaceholder": "Название поставщика", "providerUrl": "Адрес поставщика", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 2196c0372..15b2fb111 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1047,6 +1047,7 @@ "clear": "クリア", "close": "閉じる", "collapse": "折りたたむ", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "コピーされました", "copy": "コピー", @@ -3038,6 +3039,46 @@ "title": "エクスポートメニュー設定", "yuque": "語雀にエクスポート" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} 時間", "hour_interval_other": "{{count}} 時間", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "利用可能なプロンプトはありません", "requiredField": "必須フィールド" }, + "protocolInstallWarning": { + "command": "起動コマンド", + "message": "このMCPは外部ソースからプロトコル経由でインストールされました。不明なツールを実行すると、コンピューターに危害を及ぼす可能性があります。", + "run": "走る", + "title": "外部のMCPを実行しますか?" + }, "provider": "プロバイダー", "providerPlaceholder": "プロバイダー名", "providerUrl": "プロバイダーURL", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index aee876b55..0253dba3c 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1047,6 +1047,7 @@ "clear": "Limpar", "close": "Fechar", "collapse": "Recolher", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar Configurações do Menu", "yuque": "Exportar para Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "Nenhuma dica disponível", "requiredField": "Campo obrigatório" }, + "protocolInstallWarning": { + "command": "Comando de inicialização", + "message": "Este MCP foi instalado a partir de uma fonte externa via protocolo. Executar ferramentas desconhecidas pode prejudicar seu computador.", + "run": "Correr", + "title": "Executar MCP externo?" + }, "provider": "Fornecedor", "providerPlaceholder": "Nome do Fornecedor", "providerUrl": "URL do Fornecedor", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index ea9b78b71..7e31339a5 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1047,6 +1047,7 @@ "clear": "Очистить", "close": "Закрыть", "collapse": "Свернуть", + "completed": "[to be translated]:Completed", "confirm": "Подтверждение", "copied": "Скопировано", "copy": "Копировать", @@ -3038,6 +3039,46 @@ "title": "Настройки меню экспорта", "yuque": "Экспорт в Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} час", "hour_interval_other": "{{count}} часов", "joplin": { @@ -3784,6 +3825,12 @@ "noPromptsAvailable": "Нет доступных подсказок", "requiredField": "Обязательное поле" }, + "protocolInstallWarning": { + "command": "Команда запуска", + "message": "Этот MCP был установлен из внешнего источника через протокол. Запуск неизвестных инструментов может повредить ваш компьютер.", + "run": "Беги", + "title": "Запускать внешний MCP?" + }, "provider": "Провайдер", "providerPlaceholder": "Имя провайдера", "providerUrl": "URL провайдера", diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 4a6415e42..4c216cf16 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -5,7 +5,6 @@ import { useAssistants } from '@renderer/hooks/useAssistant' import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { useRuntime } from '@renderer/hooks/useRuntime' import { useActiveTopic } from '@renderer/hooks/useTopic' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import NavigationService from '@renderer/services/NavigationService' import { newMessagesActions } from '@renderer/store/newMessage' import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime' @@ -35,8 +34,10 @@ const HomePage: FC = () => { const location = useLocation() const state = location.state - const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0]) - const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic) + const [activeAssistant, _setActiveAssistant] = useState( + state?.assistant || _activeAssistant || assistants[0] + ) + const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', state?.topic) const [showAssistants] = usePreference('assistant.tab.show') const [showTopics] = usePreference('topic.tab.show') const [topicPosition] = usePreference('topic.position') @@ -47,16 +48,20 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant const setActiveAssistant = useCallback( + // TODO: allow to set it as null. (newAssistant: Assistant) => { - if (newAssistant.id === activeAssistant.id) return + if (newAssistant.id === activeAssistant?.id) return startTransition(() => { _setActiveAssistant(newAssistant) + if (newAssistant.id !== 'fake') { + dispatch(setActiveAgentId(null)) + } // 同步更新 active topic,避免不必要的重新渲染 const newTopic = newAssistant.topics[0] _setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic)) }) }, - [_setActiveTopic, activeAssistant] + [_setActiveTopic, activeAssistant?.id, dispatch] ) const setActiveTopic = useCallback( @@ -80,19 +85,6 @@ const HomePage: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state]) - useEffect(() => { - const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => { - const newAssistant = assistants.find((a) => a.id === assistantId) - if (newAssistant) { - setActiveAssistant(newAssistant) - } - }) - - return () => { - unsubscribe() - } - }, [assistants, setActiveAssistant]) - useEffect(() => { const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics window.api.window.setMinimumSize(canMinimize ? SECOND_MIN_WINDOW_WIDTH : MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) @@ -102,29 +94,6 @@ const HomePage: FC = () => { } }, [showAssistants, showTopics, topicPosition]) - useEffect(() => { - if (activeTopicOrSession === 'session') { - setActiveAssistant({ - id: 'fake', - name: '', - prompt: '', - topics: [ - { - id: 'fake', - assistantId: 'fake', - name: 'fake', - createdAt: '', - updatedAt: '', - messages: [] - } as unknown as Topic - ], - type: 'chat' - }) - } else if (activeTopicOrSession === 'topic') { - dispatch(setActiveAgentId(null)) - } - }, [activeTopicOrSession, dispatch, setActiveAssistant]) - return ( {isLeftNavbar && ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx new file mode 100644 index 000000000..2c68beb18 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx @@ -0,0 +1,16 @@ +import { AccordionItem } from '@heroui/react' +import { PencilRuler } from 'lucide-react' + +import { ToolTitle } from './GenericTools' +import type { SkillToolInput, SkillToolOutput } from './types' + +export function SkillTool({ input, output }: { input: SkillToolInput; output?: SkillToolOutput }) { + return ( + } label="Skill" params={input.command} />}> + {output} + + ) +} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx index f12d07295..8d1f5eee8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx @@ -17,6 +17,7 @@ import { MultiEditTool } from './MultiEditTool' import { NotebookEditTool } from './NotebookEditTool' import { ReadTool } from './ReadTool' import { SearchTool } from './SearchTool' +import { SkillTool } from './SkillTool' import { TaskTool } from './TaskTool' import { TodoWriteTool } from './TodoWriteTool' import type { ToolInput, ToolOutput } from './types' @@ -25,6 +26,7 @@ import { UnknownToolRenderer } from './UnknownToolRenderer' import { WebFetchTool } from './WebFetchTool' import { WebSearchTool } from './WebSearchTool' import { WriteTool } from './WriteTool' + const logger = loggerService.withContext('MessageAgentTools') // 创建工具渲染器映射,这样就实现了完全的类型安全 @@ -43,7 +45,8 @@ export const toolRenderers = { [AgentToolsType.MultiEdit]: MultiEditTool, [AgentToolsType.BashOutput]: BashOutputTool, [AgentToolsType.NotebookEdit]: NotebookEditTool, - [AgentToolsType.ExitPlanMode]: ExitPlanModeTool + [AgentToolsType.ExitPlanMode]: ExitPlanModeTool, + [AgentToolsType.Skill]: SkillTool } as const // 类型守卫函数 diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts index 1abd47486..f4271b3a2 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts @@ -1,4 +1,5 @@ export enum AgentToolsType { + Skill = 'Skill', Read = 'Read', Task = 'Task', Bash = 'Bash', @@ -22,6 +23,15 @@ export type TextOutput = { } // Read 工具的类型定义 +export interface SkillToolInput { + /** + * The skill to use + */ + command: string +} + +export type SkillToolOutput = string + export interface ReadToolInput { /** * The absolute path to the file to read diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx index 0a851056a..f78f5e221 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx @@ -2,6 +2,7 @@ import type { NormalToolResponse } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { MessageAgentTools } from './MessageAgentTools' +import { AgentToolsType } from './MessageAgentTools/types' import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch' import { MessageMemorySearchToolTitle } from './MessageMemorySearch' import { MessageWebSearchToolTitle } from './MessageWebSearch' @@ -9,27 +10,12 @@ import { MessageWebSearchToolTitle } from './MessageWebSearch' interface Props { block: ToolMessageBlock } -const prefix = 'builtin_' -const agentPrefix = 'mcp__' -const agentTools = [ - 'Read', - 'Task', - 'Bash', - 'Search', - 'Glob', - 'TodoWrite', - 'WebSearch', - 'Grep', - 'Write', - 'WebFetch', - 'Edit', - 'MultiEdit', - 'BashOutput', - 'NotebookEdit', - 'ExitPlanMode' -] -const isAgentTool = (toolName: string) => { - if (agentTools.includes(toolName) || toolName.startsWith(agentPrefix)) { +const builtinToolsPrefix = 'builtin_' +const agentMcpToolsPrefix = 'mcp__' +const agentTools = Object.values(AgentToolsType) + +const isAgentTool = (toolName: AgentToolsType) => { + if (agentTools.includes(toolName) || toolName.startsWith(agentMcpToolsPrefix)) { return true } return false @@ -38,8 +24,8 @@ const isAgentTool = (toolName: string) => { const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => { let toolName = toolResponse.tool.name const toolType = toolResponse.tool.type - if (toolName.startsWith(prefix)) { - toolName = toolName.slice(prefix.length) + if (toolName.startsWith(builtinToolsPrefix)) { + toolName = toolName.slice(builtinToolsPrefix.length) switch (toolName) { case 'web_search': case 'web_search_preview': @@ -51,7 +37,7 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => default: return null } - } else if (isAgentTool(toolName)) { + } else if (isAgentTool(toolName as AgentToolsType)) { return } return null diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index d530c4151..e2678357c 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -1,5 +1,5 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' -import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react' +import { Button, Chip, ScrollShadow } from '@heroui/react' import { loggerService } from '@logger' import { useAppDispatch, useAppSelector } from '@renderer/store' import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions' @@ -54,7 +54,6 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { const isSubmittingAllow = request?.status === 'submitting-allow' const isSubmittingDeny = request?.status === 'submitting-deny' const isSubmitting = isSubmittingAllow || isSubmittingDeny - const hasSuggestions = (request?.suggestions?.length ?? 0) > 0 const handleDecision = useCallback( async ( @@ -147,37 +146,16 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { {t('agent.toolPermission.button.cancel')} - {hasSuggestions ? ( - - - - - ) : ( - - )} + + + {t('settings.data.data.title')} @@ -635,7 +651,9 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> - + @@ -648,7 +666,7 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} /> - @@ -658,7 +676,9 @@ const DataSettings: FC = () => { {t('settings.data.app_knowledge.label')} - + @@ -668,14 +688,16 @@ const DataSettings: FC = () => { {cacheSize && ({cacheSize}MB)} - + {t('settings.general.reset.title')} - diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index f62369c30..dd9e11a4b 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -145,6 +145,7 @@ const AddMcpServerModal: FC = ({ // Process DXT file try { + const installTimestamp = Date.now() const result = await window.api.mcp.uploadDxt(dxtFile) if (!result.success) { @@ -195,7 +196,11 @@ const AddMcpServerModal: FC = ({ logoUrl: manifest.icon ? `${extractDir}/${manifest.icon}` : undefined, provider: manifest.author?.name, providerUrl: manifest.homepage || manifest.repository?.url, - tags: manifest.keywords + tags: manifest.keywords, + installSource: 'manual', + isTrusted: true, + installedAt: installTimestamp, + trustedAt: installTimestamp } onSuccess(newServer) @@ -260,12 +265,17 @@ const AddMcpServerModal: FC = ({ } // 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框 + const installTimestamp = Date.now() const newServer: MCPServer = { id: nanoid(), ...serverToAdd, name: serverToAdd.name || t('settings.mcp.newServer'), baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '', - isActive: false // 初始狀態為非啟用 + isActive: false, // 初始狀態為非啟用 + installSource: 'manual', + isTrusted: true, + installedAt: installTimestamp, + trustedAt: installTimestamp } onSuccess(newServer) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 6b90a7dca..2459e5db1 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -6,6 +6,7 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar' import { EditIcon, RefreshIcon } from '@renderer/components/Icons' import Scrollbar from '@renderer/components/Scrollbar' import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust' import type { MCPServer } from '@renderer/types' import { formatMcpError } from '@renderer/utils/error' import { matchKeywordsInString } from '@renderer/utils/match' @@ -30,6 +31,7 @@ const logger = loggerService.withContext('McpServersList') const McpServersList: FC = () => { const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers() + const { ensureServerTrusted } = useMCPServerTrust() const { t } = useTranslation() const navigate = useNavigate() const [isAddModalVisible, setIsAddModalVisible] = useState(false) @@ -158,30 +160,37 @@ const McpServersList: FC = () => { ) const handleToggleActive = async (server: MCPServer, active: boolean) => { - setLoadingServerIds((prev) => new Set(prev).add(server.id)) - const oldActiveState = server.isActive - logger.silly('toggle activate', { serverId: server.id, active }) + let serverForUpdate = server + if (active) { + const trustedServer = await ensureServerTrusted(server) + if (!trustedServer) { + return + } + serverForUpdate = trustedServer + } + + setLoadingServerIds((prev) => new Set(prev).add(serverForUpdate.id)) + const oldActiveState = serverForUpdate.isActive + logger.silly('toggle activate', { serverId: serverForUpdate.id, active }) try { if (active) { - // Fetch version when server is activated - await fetchServerVersion({ ...server, isActive: active }) + await fetchServerVersion({ ...serverForUpdate, isActive: active }) } else { - await window.api.mcp.stopServer(server) - // Clear version when server is deactivated - setServerVersions((prev) => ({ ...prev, [server.id]: null })) + await window.api.mcp.stopServer(serverForUpdate) + setServerVersions((prev) => ({ ...prev, [serverForUpdate.id]: null })) } - updateMCPServer({ ...server, isActive: active }) + updateMCPServer({ ...serverForUpdate, isActive: active }) } catch (error: any) { window.modal.error({ title: t('settings.mcp.startError'), content: formatMcpError(error), centered: true }) - updateMCPServer({ ...server, isActive: oldActiveState }) + updateMCPServer({ ...serverForUpdate, isActive: oldActiveState }) } finally { setLoadingServerIds((prev) => { const next = new Set(prev) - next.delete(server.id) + next.delete(serverForUpdate.id) return next }) } diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 60327a045..92479601c 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -5,6 +5,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js' import { DeleteIcon } from '@renderer/components/Icons' import { useTheme } from '@renderer/context/ThemeProvider' import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers' +import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust' import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription' import type { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types' import { formatMcpError } from '@renderer/utils/error' @@ -84,6 +85,7 @@ const McpSettings: React.FC = () => { const decodedServerId = serverId ? decodeURIComponent(serverId) : '' const server = useMCPServer(decodedServerId).server as MCPServer const { deleteMCPServer, updateMCPServer } = useMCPServers() + const { ensureServerTrusted } = useMCPServerTrust() const [serverType, setServerType] = useState('stdio') const [form] = Form.useForm() const [loading, setLoading] = useState(false) @@ -269,6 +271,7 @@ const McpSettings: React.FC = () => { // set basic fields const mcpServer: MCPServer = { + ...server, id: server.id, name: values.name, type: values.serverType || server.type, @@ -404,34 +407,43 @@ const McpSettings: React.FC = () => { } await form.validateFields() - setLoadingServer(server.id) - const oldActiveState = server.isActive + let serverForUpdate = server + if (active) { + const trustedServer = await ensureServerTrusted(server) + if (!trustedServer) { + return + } + serverForUpdate = trustedServer + } + + setLoadingServer(serverForUpdate.id) + const oldActiveState = serverForUpdate.isActive try { if (active) { - const localTools = await window.api.mcp.listTools(server) + const localTools = await window.api.mcp.listTools(serverForUpdate) setTools(localTools) - const localPrompts = await window.api.mcp.listPrompts(server) + const localPrompts = await window.api.mcp.listPrompts(serverForUpdate) setPrompts(localPrompts) - const localResources = await window.api.mcp.listResources(server) + const localResources = await window.api.mcp.listResources(serverForUpdate) setResources(localResources) - const version = await window.api.mcp.getServerVersion(server) + const version = await window.api.mcp.getServerVersion(serverForUpdate) setServerVersion(version) } else { - await window.api.mcp.stopServer(server) + await window.api.mcp.stopServer(serverForUpdate) setServerVersion(null) } - updateMCPServer({ ...server, isActive: active }) + updateMCPServer({ ...serverForUpdate, isActive: active }) } catch (error: any) { window.modal.error({ title: t('settings.mcp.startError'), content: formatMcpError(error as McpError), centered: true }) - updateMCPServer({ ...server, isActive: oldActiveState }) + updateMCPServer({ ...serverForUpdate, isActive: oldActiveState }) } finally { setLoadingServer(null) } diff --git a/src/renderer/src/pages/settings/MCPSettings/ProtocolInstallWarning.tsx b/src/renderer/src/pages/settings/MCPSettings/ProtocolInstallWarning.tsx new file mode 100644 index 000000000..a05391a2e --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/ProtocolInstallWarning.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +interface ProtocolInstallWarningContentProps { + message: string + commandLabel: string + commandPreview: string +} + +/** + * Warning content component for protocol-installed MCP servers + * Displays a security warning and the command that will be executed + */ +const ProtocolInstallWarningContent: React.FC = ({ + message, + commandLabel, + commandPreview +}) => { + return ( +
+

{message}

+ {commandPreview && ( +
+
{commandLabel}
+
+            {commandPreview}
+          
+
+ )} +
+ ) +} + +export default ProtocolInstallWarningContent diff --git a/src/renderer/src/pages/settings/MCPSettings/utils.ts b/src/renderer/src/pages/settings/MCPSettings/utils.ts index 79a7cb04f..729d8745e 100644 --- a/src/renderer/src/pages/settings/MCPSettings/utils.ts +++ b/src/renderer/src/pages/settings/MCPSettings/utils.ts @@ -1,51 +1,58 @@ -/** - * MCP Settings 页面导航工具函数 - */ +import { loggerService } from '@logger' +import type { MCPServer } from '@renderer/types' -export const MCPRoutes = { - // 管理类页面 - servers: '/settings/mcp/servers', - npxSearch: '/settings/mcp/npx-search', - mcpInstall: '/settings/mcp/mcp-install', - - // 发现类页面 - builtin: '/settings/mcp/builtin', - marketplaces: '/settings/mcp/marketplaces', - - // 服务商页面 - modelscope: '/settings/mcp/modelscope', - tokenflux: '/settings/mcp/tokenflux', - lanyun: '/settings/mcp/lanyun', - '302ai': '/settings/mcp/302ai', - bailian: '/settings/mcp/bailian' -} as const +const logger = loggerService.withContext('MCPSettings/utils') /** - * 导航到 MCP 设置页面的指定部分 - * @param page 页面标识 - * @returns 路由路径 + * Get command preview string from MCP server configuration + * @param server - The MCP server to extract command from + * @returns Formatted command string with arguments */ -export function getMCPRoute(page: keyof typeof MCPRoutes): string { - return MCPRoutes[page] +export const getCommandPreview = (server: MCPServer): string => { + return [server.command, ...(server.args ?? [])] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') } /** - * 生成 MCP 服务商页面路由 - * @param providerKey 服务商标识 - * @returns 路由路径 + * Ensures a server is trusted before proceeding (pure logic, no UI) + * @param currentServer - The server to verify trust for + * @param requestConfirm - Callback to request user confirmation + * @param updateServer - Callback to update server state + * @returns The trusted server if confirmed, or null if user declined */ -export function getMCPProviderRoute(providerKey: string): string { - return `/settings/mcp/${providerKey}` -} +export async function ensureServerTrusted( + currentServer: MCPServer, + requestConfirm: (server: MCPServer) => Promise, + updateServer: (server: MCPServer) => void +): Promise { + const isProtocolInstall = currentServer.installSource === 'protocol' -/** - * 生成 MCP 服务器设置页面路由 - * @param serverId 服务器ID - * @returns 路由路径 - */ -export function getMCPServerSettingsRoute(serverId: string): string { - return `/settings/mcp/settings/${serverId}` -} + logger.silly('ensureServerTrusted', { + serverId: currentServer.id, + installSource: currentServer.installSource, + isTrusted: currentServer.isTrusted + }) -// 类型定义 -export type MCPPage = keyof typeof MCPRoutes + // Early return if no trust verification needed + if (!isProtocolInstall || currentServer.isTrusted) { + return currentServer + } + + // Request user confirmation via callback + const confirmed = await requestConfirm(currentServer) + + if (!confirmed) { + return null + } + + // Update server with trust information + const trustedServer = { + ...currentServer, + installSource: 'protocol' as const, + isTrusted: true, + trustedAt: Date.now() + } + updateServer(trustedServer) + return trustedServer +} diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index ce5895fc3..badaaccf0 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -18,7 +18,6 @@ export const EVENT_NAMES = { SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS', SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR', SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR', - SWITCH_ASSISTANT: 'SWITCH_ASSISTANT', NEW_CONTEXT: 'NEW_CONTEXT', NEW_BRANCH: 'NEW_BRANCH', COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE', diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 40424effe..f35f2caee 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -71,7 +71,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 168, + version: 169, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index eb659399b..aef38bcbe 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -79,7 +79,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ command: 'npx', args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'], isActive: false, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), @@ -91,14 +93,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ MEMORY_FILE_PATH: 'YOUR_MEMORY_FILE_PATH' }, shouldConfig: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), name: BuiltinMCPServerNames.sequentialThinking, type: 'inMemory', isActive: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), @@ -109,14 +115,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ BRAVE_API_KEY: 'YOUR_API_KEY' }, shouldConfig: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), name: BuiltinMCPServerNames.fetch, type: 'inMemory', isActive: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), @@ -125,7 +135,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'], shouldConfig: true, isActive: false, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), @@ -136,14 +148,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ DIFY_KEY: 'YOUR_DIFY_KEY' }, shouldConfig: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), name: BuiltinMCPServerNames.python, type: 'inMemory', isActive: false, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true }, { id: nanoid(), @@ -155,7 +171,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ DIDI_API_KEY: 'YOUR_DIDI_API_KEY' }, shouldConfig: true, - provider: 'CherryAI' + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true } ] as const diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index fd3e0f4f3..e2763ea0b 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -32,7 +32,7 @@ import type { TranslateLanguageCode, WebSearchProvider } from '@renderer/types' -import { isSystemProvider, SystemProviderIds } from '@renderer/types' +import { isBuiltinMCPServer, isSystemProvider, SystemProviderIds } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { defaultByPassRules } from '@shared/config/constant' import { TRANSLATE_PROMPT } from '@shared/config/prompts' @@ -2771,6 +2771,23 @@ const migrateConfig = { logger.error('migrate 168 error', error as Error) return state } + }, + '169': (state: RootState) => { + try { + if (state?.mcp?.servers) { + state.mcp.servers = state.mcp.servers.map((server) => { + const inferredSource = isBuiltinMCPServer(server) ? 'builtin' : 'unknown' + return { + ...server, + installSource: inferredSource + } + }) + } + return state + } catch (error) { + logger.error('migrate 169 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index e73fdc7f0..b66e7475b 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -8,7 +8,7 @@ import type { ModelMessage, TextStreamPart } from 'ai' import * as z from 'zod' import type { Message, MessageBlock } from './newMessage' -import { PluginMetadataSchema } from './plugin' +import { InstalledPluginSchema, PluginMetadataSchema } from './plugin' // ------------------ Core enums and helper types ------------------ export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']) @@ -58,30 +58,7 @@ export const AgentConfigurationSchema = z // https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default' - max_turns: z.number().optional().default(100), // Maximum number of interaction turns, default to 100 - - // Plugin metadata - installed_plugins: z - .array( - z.object({ - sourcePath: z.string(), // Full source path for re-install/updates - filename: z.string(), // Destination filename (unique) - type: z.enum(['agent', 'command', 'skill']), - name: z.string(), - description: z.string().optional(), - allowed_tools: z.array(z.string()).optional(), - tools: z.array(z.string()).optional(), - category: z.string().optional(), - tags: z.array(z.string()).optional(), - version: z.string().optional(), - author: z.string().optional(), - contentHash: z.string(), // Detect file modifications - installedAt: z.number(), // Track installation time - updatedAt: z.number().optional() // Track updates - }) - ) - .optional() - .default([]) + max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100 }) .loose() @@ -250,6 +227,25 @@ export type SessionForm = CreateSessionForm | UpdateSessionForm export type UpdateAgentBaseForm = Partial & { id: string } +// --------------------- Components & Hooks ---------------------- + +export type UpdateAgentBaseOptions = { + /** Whether to show success toast after updating. Defaults to true. */ + showSuccessToast?: boolean +} + +export type UpdateAgentFunction = ( + form: UpdateAgentForm, + options?: UpdateAgentBaseOptions +) => Promise + +export type UpdateAgentSessionFunction = ( + form: UpdateSessionForm, + options?: UpdateAgentBaseOptions +) => Promise + +export type UpdateAgentFunctionUnion = UpdateAgentFunction | UpdateAgentSessionFunction + // ------------------ API data transfer objects ------------------ export interface CreateAgentRequest extends AgentBase { type: AgentType @@ -264,7 +260,8 @@ export interface UpdateAgentRequest extends Partial {} export type ReplaceAgentRequest = AgentBase export const GetAgentResponseSchema = AgentEntitySchema.extend({ - tools: z.array(ToolSchema).optional() // All tools available to the agent (including built-in and custom) + tools: z.array(ToolSchema).optional(), // All tools available to the agent (including built-in and custom) + installed_plugins: z.array(InstalledPluginSchema).optional() // Plugins loaded from .claude/plugins.json cache }) export type GetAgentResponse = z.infer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 2e1039b3b..329f37626 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -11,7 +11,7 @@ import type { StreamTextParams } from './aiCoreTypes' import type { Chunk } from './chunk' import type { FileMetadata } from './file' import type { KnowledgeBase, KnowledgeReference } from './knowledge' -import type { MCPConfigSample, McpServerType } from './mcp' +import type { MCPConfigSample, MCPServerInstallSource, McpServerType } from './mcp' import type { Message } from './newMessage' import type { BaseTool, MCPTool } from './tool' @@ -682,6 +682,14 @@ export interface MCPServer { shouldConfig?: boolean /** 用于标记服务器是否运行中 */ isActive: boolean + /** 标记 MCP 安装来源,例如 builtin/manual/protocol */ + installSource?: MCPServerInstallSource + /** 指示用户是否已信任该 MCP */ + isTrusted?: boolean + /** 首次标记为信任的时间戳 */ + trustedAt?: number + /** 安装时间戳 */ + installedAt?: number } export type BuiltinMCPServer = MCPServer & { diff --git a/src/renderer/src/types/mcp.ts b/src/renderer/src/types/mcp.ts index c447d697e..48f48a616 100644 --- a/src/renderer/src/types/mcp.ts +++ b/src/renderer/src/types/mcp.ts @@ -27,6 +27,9 @@ export const McpServerTypeSchema = z }) .pipe(z.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])) // 大多数情况下默认使用 stdio +export const MCPServerInstallSourceSchema = z.enum(['builtin', 'manual', 'protocol', 'unknown']).default('unknown') +export type MCPServerInstallSource = z.infer + /** * 定义单个 MCP 服务器的配置。 * FIXME: 为了兼容性,暂时允许用户编辑任意字段,这可能会导致问题。 @@ -168,7 +171,11 @@ export const McpServerConfigSchema = z * 是否激活 * 可选。用于标识服务器是否处于激活状态。 */ - isActive: z.boolean().optional().describe('Whether the server is active') + isActive: z.boolean().optional().describe('Whether the server is active'), + installSource: MCPServerInstallSourceSchema.optional().describe('Where the MCP server was installed from'), + isTrusted: z.boolean().optional().describe('Whether the MCP server has been trusted by user'), + trustedAt: z.number().optional().describe('Timestamp when the server was trusted'), + installedAt: z.number().optional().describe('Timestamp when the server was installed') }) .strict() // 在这里定义额外的校验逻辑 diff --git a/src/renderer/src/types/plugin.ts b/src/renderer/src/types/plugin.ts index 352914cfc..7a6fa0fb9 100644 --- a/src/renderer/src/types/plugin.ts +++ b/src/renderer/src/types/plugin.ts @@ -43,6 +43,15 @@ export const InstalledPluginSchema = z.object({ export type InstalledPlugin = z.infer +// Cache file schema for .claude/plugins.json +export const CachedPluginsDataSchema = z.object({ + version: z.number().default(1), + lastUpdated: z.number(), // Unix timestamp in milliseconds + plugins: z.array(InstalledPluginSchema) +}) + +export type CachedPluginsData = z.infer + // Error handling types export type PluginError = | { type: 'PATH_TRAVERSAL'; message: string; path: string } diff --git a/yarn.lock b/yarn.lock index 2fc4406a9..06005e5f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14064,6 +14064,13 @@ __metadata: languageName: node linkType: hard +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.2 + resolution: "@socket.io/component-emitter@npm:3.1.2" + checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" @@ -15286,7 +15293,7 @@ __metadata: languageName: node linkType: hard -"@types/cors@npm:^2.8.19": +"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19": version: 2.8.19 resolution: "@types/cors@npm:2.8.19" dependencies: @@ -15859,6 +15866,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=10.0.0": + version: 24.3.1 + resolution: "@types/node@npm:24.3.1" + dependencies: + undici-types: "npm:~7.10.0" + checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928 + languageName: node + linkType: hard + "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -17396,6 +17412,7 @@ __metadata: pdf-parse: "npm:^1.1.1" playwright: "npm:^1.55.1" proxy-agent: "npm:^6.5.0" + qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" @@ -17427,6 +17444,7 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" + socket.io: "npm:^4.8.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" @@ -17502,6 +17520,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.4": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -18281,6 +18309,13 @@ __metadata: languageName: node linkType: hard +"base64id@npm:2.0.0, base64id@npm:~2.0.0": + version: 2.0.0 + resolution: "base64id@npm:2.0.0" + checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -19597,7 +19632,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1": +"cookie@npm:^0.7.1, cookie@npm:~0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -19627,7 +19662,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5": +"cors@npm:^2.8.5, cors@npm:~2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" dependencies: @@ -20302,6 +20337,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + languageName: node + linkType: hard + "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -21241,6 +21288,30 @@ __metadata: languageName: node linkType: hard +"engine.io-parser@npm:~5.2.1": + version: 5.2.3 + resolution: "engine.io-parser@npm:5.2.3" + checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 + languageName: node + linkType: hard + +"engine.io@npm:~6.6.0": + version: 6.6.4 + resolution: "engine.io@npm:6.6.4" + dependencies: + "@types/cors": "npm:^2.8.12" + "@types/node": "npm:>=10.0.0" + accepts: "npm:~1.3.4" + base64id: "npm:2.0.0" + cookie: "npm:~0.7.2" + cors: "npm:~2.8.5" + debug: "npm:~4.3.1" + engine.io-parser: "npm:~5.2.1" + ws: "npm:~8.17.1" + checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -26622,7 +26693,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -27086,6 +27157,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + "negotiator@npm:^0.6.3": version: 0.6.4 resolution: "negotiator@npm:0.6.4" @@ -28809,6 +28887,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^4.2.0": + version: 4.2.0 + resolution: "qrcode.react@npm:4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 + languageName: node + linkType: hard + "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -31289,6 +31376,41 @@ __metadata: languageName: node linkType: hard +"socket.io-adapter@npm:~2.5.2": + version: 2.5.5 + resolution: "socket.io-adapter@npm:2.5.5" + dependencies: + debug: "npm:~4.3.4" + ws: "npm:~8.17.1" + checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.4": + version: 4.2.4 + resolution: "socket.io-parser@npm:4.2.4" + dependencies: + "@socket.io/component-emitter": "npm:~3.1.0" + debug: "npm:~4.3.1" + checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 + languageName: node + linkType: hard + +"socket.io@npm:^4.8.1": + version: 4.8.1 + resolution: "socket.io@npm:4.8.1" + dependencies: + accepts: "npm:~1.3.4" + base64id: "npm:~2.0.0" + cors: "npm:~2.8.5" + debug: "npm:~4.3.2" + engine.io: "npm:~6.6.0" + socket.io-adapter: "npm:~2.5.2" + socket.io-parser: "npm:~4.2.4" + checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -32939,6 +33061,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.10.0": + version: 7.10.0 + resolution: "undici-types@npm:7.10.0" + checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635 + languageName: node + linkType: hard + "undici@npm:6.21.2": version: 6.21.2 resolution: "undici@npm:6.21.2" @@ -33999,6 +34128,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:~8.17.1": + version: 8.17.1 + resolution: "ws@npm:8.17.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe + languageName: node + linkType: hard + "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version: 0.20.2 resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"