From d7126e0611283684e00e35fcdd4b168d1139152a Mon Sep 17 00:00:00 2001 From: lsust Date: Thu, 23 Oct 2025 13:52:55 +0800 Subject: [PATCH 01/23] feat: add multi-provider support and UI configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Support multiple AI providers (DeepSeek, Qwen, Google Gemini, Anthropic, OpenRouter) - Add ModelConfigBar component for in-app API key configuration - Refactor IPC handlers into modular structure - Add Chinese documentation (README.zh-CN.md, CONFIGURATION.zh-CN.md) - Update configuration guide with detailed setup instructions Breaking changes: - Removed OpenAI API configuration - Restructured environment variable names (BAILIAN -> QWEN) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.template | 21 +- README.md | 32 ++- README.zh-CN.md | 120 +++++++++ docs/CONFIGURATION.md | 230 +++++++++++++---- docs/CONFIGURATION.zh-CN.md | 279 ++++++++++++++++++++ electron/main/index.ts | 218 +--------------- electron/main/ipc/config-handlers.ts | 96 +++++++ electron/main/ipc/eko-handlers.ts | 80 ++++++ electron/main/ipc/history-handlers.ts | 114 ++++++++ electron/main/ipc/index.ts | 25 ++ electron/main/ipc/view-handlers.ts | 58 +++++ electron/main/services/eko-service.ts | 207 +++++++++------ electron/main/utils/config-manager.ts | 301 ++++++++++++++++++++++ electron/preload/index.d.ts | 58 ----- electron/preload/index.ts | 8 + src/components/ModelConfigBar.tsx | 275 ++++++++++++++++++++ src/components/chat/MessageComponents.tsx | 6 +- src/hooks/useTaskManager.ts | 39 +++ src/pages/home.tsx | 33 ++- src/pages/main.tsx | 110 +++++--- src/type.d.ts | 37 +++ src/utils/messageTransform.ts | 29 ++- 22 files changed, 1889 insertions(+), 487 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 docs/CONFIGURATION.zh-CN.md create mode 100644 electron/main/ipc/config-handlers.ts create mode 100644 electron/main/ipc/eko-handlers.ts create mode 100644 electron/main/ipc/history-handlers.ts create mode 100644 electron/main/ipc/index.ts create mode 100644 electron/main/ipc/view-handlers.ts delete mode 100644 electron/preload/index.d.ts create mode 100644 src/components/ModelConfigBar.tsx diff --git a/.env.template b/.env.template index ac257fc..d631d7b 100644 --- a/.env.template +++ b/.env.template @@ -2,21 +2,24 @@ # =================== # DeepSeek API Configuration +# Get your API key from: https://platform.deepseek.com/api_keys DEEPSEEK_API_KEY= DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 -# OpenAI API Configuration -OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.openai.com/v1 +# Alibaba Cloud Qwen API Configuration +# Get your API key from: https://bailian.console.aliyun.com/ +QWEN_API_KEY= -# Anthropic API Configuration -ANTHROPIC_API_KEY=your_anthropic_api_key_here -ANTHROPIC_BASE_URL=https://api.anthropic.com/v1 +# Google Gemini API Configuration +# Get your API key from: https://aistudio.google.com/app/apikey +GOOGLE_API_KEY= -# Alibaba Cloud Bailian API Keys (for Douyin/Xiaohongshu services) -BAILIAN_API_KEY= +# Anthropic Claude API Configuration +# Get your API key from: https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY= -# OpenRouter API Configuration +# OpenRouter API Configuration (supports multiple providers) +# Get your API key from: https://openrouter.ai/keys OPENROUTER_API_KEY= # Text-to-Speech Configuration diff --git a/README.md b/README.md index 73bda7f..f3ce5a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# DeepFundAI Browser +# Manus Electron -An AI-powered intelligent browser built with Next.js and Electron. Features multi-modal AI task execution, scheduled tasks, social media integration, and advanced file management capabilities. +[English](./README.md) | [简体中文](./README.zh-CN.md) + +An AI-powered intelligent browser built with Next.js and Electron. Features multi-modal AI task execution, scheduled tasks, social media integration, and advanced file management capabilities with support for multiple AI providers. Built with [Next.js](https://nextjs.org) and [Electron](https://electronjs.org). @@ -28,7 +30,7 @@ Before running the application, you need to configure API keys: cp .env.template .env.local # Edit .env.local and fill in your API keys -# Required: DEEPSEEK_API_KEY, BAILIAN_API_KEY +# Supported: DEEPSEEK_API_KEY, QWEN_API_KEY, GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY ``` For detailed configuration instructions, see [CONFIGURATION.md](./docs/CONFIGURATION.md). @@ -67,12 +69,13 @@ The built application will include your API configuration, so end users don't ne ## Features -- AI-powered intelligent browser -- Multi-modal AI task execution -- Scheduled tasks system -- Social media integration (Douyin, Xiaohongshu) -- Speech recognition and text-to-speech -- File management capabilities +- **Multiple AI Providers**: Support for DeepSeek, Qwen, Google Gemini, Anthropic Claude, and OpenRouter +- **UI Configuration**: Configure AI models and API keys directly in the app, no file editing required +- **AI-Powered Browser**: Intelligent browser with automated task execution +- **Multi-Modal AI**: Vision and text processing capabilities +- **Scheduled Tasks**: Create and manage automated recurring tasks +- **Speech & TTS**: Voice recognition and text-to-speech integration +- **File Management**: Advanced file operations and management ## Screenshots @@ -96,12 +99,13 @@ View past tasks with search and playback capabilities. ![History](./docs/shotscreen/history.png) -## API Services Used +## Supported AI Providers -- **DeepSeek**: Main AI language model -- **Alibaba Cloud Bailian**: Vision model and social media services -- **OpenRouter**: Alternative AI models -- **Microsoft Azure**: Text-to-speech services +- **DeepSeek**: deepseek-chat, deepseek-reasoner +- **Qwen (Alibaba Cloud)**: qwen-max, qwen-plus, qwen-vl-max +- **Google Gemini**: gemini-1.5-flash, gemini-2.0-flash, gemini-1.5-pro, and more +- **Anthropic Claude**: claude-3.7-sonnet, claude-3.5-sonnet, claude-3-opus, and more +- **OpenRouter**: Multiple providers (Claude, GPT, Gemini, Mistral, Cohere, etc.) ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..3fd177b --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,120 @@ +# Manus Electron + +[English](./README.md) | [简体中文](./README.zh-CN.md) + +一个基于 Next.js 和 Electron 构建的 AI 智能浏览器。支持多模态 AI 任务执行、定时任务、社交媒体集成以及高级文件管理功能,并支持多个 AI 提供商。 + +基于 [Next.js](https://nextjs.org) 和 [Electron](https://electronjs.org) 构建。 + +## 技术栈 + +- **前端**: Next.js 15 + React 19 +- **桌面应用**: Electron 33 +- **UI**: Ant Design + Tailwind CSS +- **状态管理**: Zustand +- **存储**: IndexedDB (via electron-store) +- **AI Agent**: @jarvis-agent (基于 [Eko](https://github.com/FellouAI/eko)) +- **构建工具**: Vite + TypeScript + +## 开发环境配置 +Node 版本: 20.19.3 + +## 快速开始 + +### 1. 配置 API 密钥 + +运行应用前,需要配置 API 密钥: + +```bash +# 复制配置模板 +cp .env.template .env.local + +# 编辑 .env.local 并填入你的 API 密钥 +# 支持: DEEPSEEK_API_KEY, QWEN_API_KEY, GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY +``` + +详细配置说明请参见 [CONFIGURATION.zh-CN.md](./docs/CONFIGURATION.zh-CN.md)。 + +### 2. 开发环境设置 + +首先,运行开发服务器: + +```bash +# 安装依赖 +pnpm install + +# 构建桌面应用客户端 +pnpm run build:deps + +# 启动 Web 开发服务器 +pnpm run next + +# 启动桌面应用 +pnpm run electron +``` + +### 3. 构建桌面应用 + +构建用于分发的桌面应用: + +```bash +# 配置生产环境 API 密钥 +# 编辑 .env.production 文件并填入实际的 API 密钥 + +# 构建应用 +pnpm run build +``` + +构建的应用将包含你的 API 配置,终端用户无需额外配置。 + +## 功能特性 + +- **多 AI 提供商支持**: 支持 DeepSeek、Qwen、Google Gemini、Anthropic Claude 和 OpenRouter +- **UI 配置**: 直接在应用中配置 AI 模型和 API 密钥,无需编辑文件 +- **AI 智能浏览器**: 具有自动化任务执行的智能浏览器 +- **多模态 AI**: 视觉和文本处理能力 +- **定时任务**: 创建和管理自动化定期任务 +- **语音识别与 TTS**: 语音识别和文字转语音集成 +- **文件管理**: 高级文件操作和管理 + +## 截图 + +### 首页 +输入任务,让 AI 自动执行。 + +![首页](./docs/shotscreen/home.png) + +### 主界面 +左侧:AI 思考和执行步骤。右侧:实时浏览器操作预览。 + +![主界面](./docs/shotscreen/main.png) + +### 定时任务 +创建具有自定义间隔和执行步骤的定时任务。 + +![定时任务](./docs/shotscreen/schedule.png) + +### 历史记录 +查看过去的任务,支持搜索和回放功能。 + +![历史记录](./docs/shotscreen/history.png) + +## 支持的 AI 提供商 + +- **DeepSeek**: deepseek-chat, deepseek-reasoner +- **Qwen (阿里云)**: qwen-max, qwen-plus, qwen-vl-max +- **Google Gemini**: gemini-1.5-flash, gemini-2.0-flash, gemini-1.5-pro 等 +- **Anthropic Claude**: claude-3.7-sonnet, claude-3.5-sonnet, claude-3-opus 等 +- **OpenRouter**: 多个提供商(Claude、GPT、Gemini、Mistral、Cohere 等) + +## 文档 + +- [配置指南](./docs/CONFIGURATION.zh-CN.md) - 详细的 API 密钥设置说明 + +## 致谢 + +特别感谢 [Eko](https://github.com/FellouAI/eko) - 一个生产就绪的 Agent 框架,为本项目提供了 AI 能力支持。 + +## 贡献 + +请确保所有 API 密钥仅在开发环境文件中配置。永远不要将实际的 API 密钥提交到仓库中。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cca4e9a..8ef4092 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,17 +1,81 @@ # Configuration Guide -This guide explains how to configure API keys and environment variables for the DeepFundAI application. +This guide explains how to configure AI models and API keys for the Manus Electron application. ## Configuration Strategy -The application uses a multi-level configuration strategy: -- **Development**: Uses `.env.local` file -- **Production**: Uses bundled `.env.production` file (packaged with the app) -- **Priority**: Bundled config > System environment variables +The application supports multiple configuration methods with the following priority: -This allows developers to configure API keys once during build, and end users don't need any additional configuration. +**Priority Order**: User UI Configuration > Environment Variables > Default Values -## Environment Variables Setup +### Configuration Methods + +1. **UI Configuration (Recommended for End Users)** + - Configure directly in the application settings + - No need to edit files or restart the app + - Changes take effect immediately + +2. **Environment Variables (For Development)** + - Uses `.env.local` file in development + - Uses bundled `.env.production` file in production builds + - Suitable for developers and automated deployments + +3. **Default Values** + - Built-in fallback values + - Used when no other configuration is provided + +## Supported AI Providers + +The application supports the following AI providers: + +| Provider | Models | Get API Key | +|----------|--------|-------------| +| **DeepSeek** | deepseek-chat, deepseek-reasoner | [platform.deepseek.com](https://platform.deepseek.com/api_keys) | +| **Qwen (Alibaba)** | qwen-max, qwen-plus, qwen-vl-max | [bailian.console.aliyun.com](https://bailian.console.aliyun.com/) | +| **Google Gemini** | gemini-1.5-flash, gemini-2.0-flash, gemini-1.5-pro, etc. | [aistudio.google.com](https://aistudio.google.com/app/apikey) | +| **Anthropic Claude** | claude-3.7-sonnet, claude-3.5-sonnet, claude-3-opus, etc. | [console.anthropic.com](https://console.anthropic.com/settings/keys) | +| **OpenRouter** | Multiple providers (Claude, GPT, Gemini, etc.) | [openrouter.ai](https://openrouter.ai/keys) | + +## UI Configuration (Recommended) + +### Configure AI Provider in the Application + +1. **Launch the Application** + - Open the Manus Electron application + +2. **Access Model Settings** + - On the home page, you'll see the model configuration panel + - The panel is located above the input area + +3. **Select Provider** + - Click the provider dropdown + - Choose from: Deepseek, Qwen, Google Gemini, Anthropic, or OpenRouter + +4. **Select Model** + - After selecting a provider, choose your preferred model + - Different providers offer different models with varying capabilities + +5. **Configure API Key** + - Click "Edit API Key" + - Enter your API key for the selected provider + - Click the checkmark to save + - API key status indicator shows: + - 🟢 **Set by user**: You configured it in the UI + - 🟢 **Set via environment variable**: Configured in .env file + - 🟡 **Not configured**: No API key found + +6. **Get API Key** + - Click "Get API Key" link to open the provider's API key page + - Sign up or log in to get your API key + - Copy and paste it into the application + +### Configuration Takes Effect Immediately + +- No need to restart the application +- Changes apply to the next message you send +- All running tasks are terminated when configuration changes + +## Environment Variables Setup (For Developers) ### 1. Copy Configuration Template @@ -30,21 +94,24 @@ Edit `.env.local` and fill in your API keys: # =================== # DeepSeek API Configuration +# Get your API key from: https://platform.deepseek.com/api_keys DEEPSEEK_API_KEY=your_actual_deepseek_api_key_here DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 -# OpenAI API Configuration -OPENAI_API_KEY=your_actual_openai_api_key_here -OPENAI_BASE_URL=https://api.openai.com/v1 +# Alibaba Cloud Qwen API Configuration +# Get your API key from: https://bailian.console.aliyun.com/ +QWEN_API_KEY=your_actual_qwen_api_key_here -# Anthropic API Configuration -ANTHROPIC_API_KEY=your_actual_anthropic_api_key_here -ANTHROPIC_BASE_URL=https://api.anthropic.com/v1 +# Google Gemini API Configuration +# Get your API key from: https://aistudio.google.com/app/apikey +GOOGLE_API_KEY=your_actual_google_api_key_here -# Alibaba Cloud Bailian API Keys (for Douyin/Xiaohongshu services) -BAILIAN_API_KEY=your_actual_bailian_douyin_api_key_here +# Anthropic Claude API Configuration +# Get your API key from: https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=your_actual_anthropic_api_key_here -# OpenRouter API Configuration +# OpenRouter API Configuration (supports multiple providers) +# Get your API key from: https://openrouter.ai/keys OPENROUTER_API_KEY=your_actual_openrouter_api_key_here # Text-to-Speech Configuration @@ -69,53 +136,77 @@ NEXT_PUBLIC_APP_ENV=development ELECTRON_IS_DEV=true ``` -## API Key Sources - -### DeepSeek -- Visit: https://platform.deepseek.com/ -- Create account and generate API key - -### OpenAI -- Visit: https://platform.openai.com/ -- Create account and generate API key +## Model Capabilities & Token Limits -### Anthropic -- Visit: https://console.anthropic.com/ -- Create account and generate API key +Different models have different maximum token limits: -### Alibaba Cloud Bailian -- Visit: https://bailian.console.aliyun.com/ -- Create service and generate API keys -- Separate keys recommended for Douyin and Xiaohongshu services +| Model | Provider | Max Tokens | Best For | +|-------|----------|------------|----------| +| deepseek-reasoner | DeepSeek | 65,536 | Complex reasoning tasks | +| claude-3-7-sonnet | Anthropic | 128,000 | Long-context tasks | +| gemini-2.0-flash-thinking | Google | 65,536 | Reasoning with multimodal | +| deepseek-chat | DeepSeek | 8,192 | General tasks | +| qwen-max | Qwen | 8,192 | Chinese language tasks | +| claude-3.5-sonnet | Anthropic | 8,000 | Balanced performance | -### OpenRouter -- Visit: https://openrouter.ai/ -- Create account and generate API key - -### Text-to-Speech -- Visit: https://azure.microsoft.com/en-us/services/cognitive-services/text-to-speech/ -- Create Azure Cognitive Services account -- Get region and API key +The application automatically configures the correct token limit based on your selected model. ## Security Notes - **Never commit actual API keys** to version control - Use `.env.local` for local development (already in `.gitignore`) -- For production, use your hosting platform's environment variable configuration +- User-configured API keys are stored securely in electron-store (encrypted) - All hardcoded API keys have been removed from source code - Configuration template provides placeholder values for security +## Configuration Priority Examples + +### Example 1: User Configuration Overrides Environment Variable + +``` +User UI: DEEPSEEK_API_KEY = "sk-user-key" +.env.local: DEEPSEEK_API_KEY = "sk-env-key" +Result: Uses "sk-user-key" +``` + +### Example 2: Environment Variable as Fallback + +``` +User UI: DEEPSEEK_API_KEY = (not set) +.env.local: DEEPSEEK_API_KEY = "sk-env-key" +Result: Uses "sk-env-key" +``` + +### Example 3: Default Values + +``` +User UI: DEEPSEEK_API_KEY = (not set) +.env.local: DEEPSEEK_API_KEY = (not set) +Result: No API key, will show error when trying to use +``` + ## Development Workflow +### For End Users +1. Launch the application +2. Click provider dropdown on home page +3. Select your preferred AI provider +4. Enter API key in the UI +5. Start chatting! + +### For Developers 1. Copy `.env.template` to `.env.local` 2. Fill in your actual API keys in `.env.local` 3. Restart the development server if it's running 4. The application will automatically use the environment variables +5. Can override specific keys in the UI if needed ## Production Deployment ### For Desktop Application Build +**Option 1: Bundle API Keys (Not Recommended for Distribution)** + Before building the desktop application, configure the `.env.production` file: ```bash @@ -129,24 +220,48 @@ Then build the application: npm run build ``` -The `.env.production` file will be bundled with the application, so end users don't need to configure anything. +The `.env.production` file will be bundled with the application. + +**Option 2: User Configuration (Recommended)** + +Build the application without API keys: -### For Web Deployment +```bash +npm run build +``` -Set the environment variables in your hosting platform: -- Vercel: Environment Variables in project settings -- Netlify: Environment Variables in site settings -- Other platforms: Refer to their documentation for environment variable setup +End users will configure their own API keys in the UI after installation. ## Troubleshooting -### Desktop Application +### UI Configuration Issues + +**Problem**: API key status shows "Not configured" +- **Solution**: Click "Edit API Key" and enter your API key +- Verify you clicked the checkmark to save + +**Problem**: Changes not taking effect +- **Solution**: Configuration reloads automatically +- Check console for error messages +- Try selecting a different model and switching back + +**Problem**: Can't find the configuration panel +- **Solution**: The model configuration panel is on the home page, above the input area +- Make sure you're on the home page, not in a chat session + +### API Key Errors + +**Problem**: "API key is invalid" error +- **Solution**: + - Verify you copied the complete API key + - Check that the API key is active in the provider's dashboard + - Ensure you have sufficient credits/quota -If you encounter API key errors in the desktop application: -1. Check that `.env.production` is properly configured before building -2. Verify the application was built after configuring `.env.production` -3. Check application logs for configuration loading messages -4. Ensure required API keys are present and valid +**Problem**: "Cannot connect to API" error +- **Solution**: + - Check your internet connection + - Verify the API provider's service is operational + - Try a different provider to isolate the issue ### Development Environment @@ -154,10 +269,11 @@ If you encounter API key errors in development: 1. Check that all required API keys are set in `.env.local` 2. Verify API keys are valid and have sufficient quota 3. Restart the development server after changing environment variables -4. Check browser console for specific error messages +4. Check browser console and terminal for specific error messages ### Common Issues -- **No API keys found**: Ensure `.env.production` is configured before building -- **Configuration not loading**: Check that the file exists in the build output -- **API authentication errors**: Verify API keys are correct and have proper permissions \ No newline at end of file +- **Configuration not saving**: Check electron-store permissions +- **API authentication errors**: Verify API keys are correct and have proper permissions +- **Model not available**: Some providers may have regional restrictions +- **Rate limiting**: You may have exceeded the API provider's rate limits \ No newline at end of file diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md new file mode 100644 index 0000000..3b26e22 --- /dev/null +++ b/docs/CONFIGURATION.zh-CN.md @@ -0,0 +1,279 @@ +# 配置指南 + +本指南介绍如何为 Manus Electron 应用配置 AI 模型和 API 密钥。 + +## 配置策略 + +应用支持多种配置方式,优先级如下: + +**优先级顺序**:用户 UI 配置 > 环境变量 > 默认值 + +### 配置方式 + +1. **UI 配置(推荐给终端用户)** + - 直接在应用设置中配置 + - 无需编辑文件或重启应用 + - 配置立即生效 + +2. **环境变量(适合开发者)** + - 开发环境使用 `.env.local` 文件 + - 生产构建使用打包的 `.env.production` 文件 + - 适合开发者和自动化部署 + +3. **默认值** + - 内置的后备值 + - 在没有其他配置时使用 + +## 支持的 AI 提供商 + +应用支持以下 AI 提供商: + +| 提供商 | 模型 | 获取 API 密钥 | +|--------|------|--------------| +| **DeepSeek** | deepseek-chat, deepseek-reasoner | [platform.deepseek.com](https://platform.deepseek.com/api_keys) | +| **Qwen (阿里云)** | qwen-max, qwen-plus, qwen-vl-max | [bailian.console.aliyun.com](https://bailian.console.aliyun.com/) | +| **Google Gemini** | gemini-1.5-flash, gemini-2.0-flash, gemini-1.5-pro 等 | [aistudio.google.com](https://aistudio.google.com/app/apikey) | +| **Anthropic Claude** | claude-3.7-sonnet, claude-3.5-sonnet, claude-3-opus 等 | [console.anthropic.com](https://console.anthropic.com/settings/keys) | +| **OpenRouter** | 多个提供商(Claude, GPT, Gemini 等) | [openrouter.ai](https://openrouter.ai/keys) | + +## UI 配置(推荐) + +### 在应用中配置 AI 提供商 + +1. **启动应用** + - 打开 Manus Electron 应用 + +2. **访问模型设置** + - 在首页,你会看到模型配置面板 + - 面板位于输入框上方 + +3. **选择提供商** + - 点击提供商下拉菜单 + - 从以下选项中选择:Deepseek、Qwen、Google Gemini、Anthropic 或 OpenRouter + +4. **选择模型** + - 选择提供商后,选择你偏好的模型 + - 不同提供商提供不同能力的模型 + +5. **配置 API 密钥** + - 点击"编辑 API 密钥" + - 输入所选提供商的 API 密钥 + - 点击对勾保存 + - API 密钥状态指示器显示: + - 🟢 **用户设置**:你在 UI 中配置的 + - 🟢 **环境变量设置**:在 .env 文件中配置的 + - 🟡 **未配置**:未找到 API 密钥 + +6. **获取 API 密钥** + - 点击"获取 API 密钥"链接打开提供商的 API 密钥页面 + - 注册或登录以获取你的 API 密钥 + - 复制并粘贴到应用中 + +### 配置立即生效 + +- 无需重启应用 +- 更改将应用于你发送的下一条消息 +- 配置更改时所有运行中的任务将被终止 + +## 环境变量配置(适合开发者) + +### 1. 复制配置模板 + +复制模板文件以创建本地环境配置: + +```bash +cp .env.template .env.local +``` + +### 2. 配置 API 密钥 + +编辑 `.env.local` 并填入你的 API 密钥: + +```bash +# AI 服务 API 密钥 +# =================== + +# DeepSeek API 配置 +# 从这里获取 API 密钥:https://platform.deepseek.com/api_keys +DEEPSEEK_API_KEY=你的_deepseek_api_密钥 +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# 阿里云通义千问 API 配置 +# 从这里获取 API 密钥:https://bailian.console.aliyun.com/ +QWEN_API_KEY=你的_qwen_api_密钥 + +# Google Gemini API 配置 +# 从这里获取 API 密钥:https://aistudio.google.com/app/apikey +GOOGLE_API_KEY=你的_google_api_密钥 + +# Anthropic Claude API 配置 +# 从这里获取 API 密钥:https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=你的_anthropic_api_密钥 + +# OpenRouter API 配置(支持多个提供商) +# 从这里获取 API 密钥:https://openrouter.ai/keys +OPENROUTER_API_KEY=你的_openrouter_api_密钥 + +# 语音转文字配置 +TTS_REGION=eastasia +TTS_KEY=你的_tts_密钥 + +# 应用设置 +# =================== + +# 截图设置 +EKO_SCREENSHOT_SCALE=0.5 +# 或者使用最大宽度进行比例缩放 +# EKO_SCREENSHOT_MAX_WIDTH=1280 + +# 开发设置 +# =================== + +# Next.js 开发设置 +NEXT_PUBLIC_APP_ENV=development + +# Electron 设置 +ELECTRON_IS_DEV=true +``` + +## 模型能力与 Token 限制 + +不同模型有不同的最大 token 限制: + +| 模型 | 提供商 | 最大 Tokens | 最适合 | +|------|--------|-------------|--------| +| deepseek-reasoner | DeepSeek | 65,536 | 复杂推理任务 | +| claude-3-7-sonnet | Anthropic | 128,000 | 长文本任务 | +| gemini-2.0-flash-thinking | Google | 65,536 | 多模态推理 | +| deepseek-chat | DeepSeek | 8,192 | 通用任务 | +| qwen-max | Qwen | 8,192 | 中文任务 | +| claude-3.5-sonnet | Anthropic | 8,000 | 平衡性能 | + +应用会根据你选择的模型自动配置正确的 token 限制。 + +## 安全注意事项 + +- **永远不要将实际的 API 密钥提交到版本控制** +- 本地开发使用 `.env.local`(已在 `.gitignore` 中) +- 用户配置的 API 密钥安全存储在 electron-store 中(已加密) +- 所有硬编码的 API 密钥已从源代码中删除 +- 配置模板提供占位符值以确保安全 + +## 配置优先级示例 + +### 示例 1:用户配置覆盖环境变量 + +``` +用户 UI:DEEPSEEK_API_KEY = "sk-user-key" +.env.local:DEEPSEEK_API_KEY = "sk-env-key" +结果:使用 "sk-user-key" +``` + +### 示例 2:环境变量作为后备 + +``` +用户 UI:DEEPSEEK_API_KEY =(未设置) +.env.local:DEEPSEEK_API_KEY = "sk-env-key" +结果:使用 "sk-env-key" +``` + +### 示例 3:默认值 + +``` +用户 UI:DEEPSEEK_API_KEY =(未设置) +.env.local:DEEPSEEK_API_KEY =(未设置) +结果:没有 API 密钥,尝试使用时会显示错误 +``` + +## 开发工作流程 + +### 终端用户 +1. 启动应用 +2. 在首页点击提供商下拉菜单 +3. 选择你偏好的 AI 提供商 +4. 在 UI 中输入 API 密钥 +5. 开始聊天! + +### 开发者 +1. 复制 `.env.template` 到 `.env.local` +2. 在 `.env.local` 中填入你的实际 API 密钥 +3. 如果开发服务器正在运行,重启它 +4. 应用将自动使用环境变量 +5. 如需要,可在 UI 中覆盖特定密钥 + +## 生产部署 + +### 桌面应用构建 + +**选项 1:打包 API 密钥(不推荐用于分发)** + +在构建桌面应用前,配置 `.env.production` 文件: + +```bash +# 编辑生产配置文件 +# 将所有占位符 API 密钥替换为实际值 +``` + +然后构建应用: + +```bash +npm run build +``` + +`.env.production` 文件将被打包到应用中。 + +**选项 2:用户配置(推荐)** + +不带 API 密钥构建应用: + +```bash +npm run build +``` + +终端用户在安装后会在 UI 中配置自己的 API 密钥。 + +## 故障排除 + +### UI 配置问题 + +**问题**:API 密钥状态显示"未配置" +- **解决方案**:点击"编辑 API 密钥"并输入你的 API 密钥 +- 确认你点击了对勾保存 + +**问题**:更改未生效 +- **解决方案**:配置会自动重新加载 +- 检查控制台是否有错误消息 +- 尝试选择不同的模型然后切换回来 + +**问题**:找不到配置面板 +- **解决方案**:模型配置面板在首页,输入框上方 +- 确保你在首页,而不是在聊天会话中 + +### API 密钥错误 + +**问题**:"API 密钥无效"错误 +- **解决方案**: + - 确认你复制了完整的 API 密钥 + - 检查 API 密钥在提供商的控制台中是否激活 + - 确保你有足够的额度/配额 + +**问题**:"无法连接到 API"错误 +- **解决方案**: + - 检查你的网络连接 + - 确认 API 提供商的服务正常运行 + - 尝试不同的提供商以隔离问题 + +### 开发环境 + +如果在开发中遇到 API 密钥错误: +1. 检查所有必需的 API 密钥是否在 `.env.local` 中设置 +2. 确认 API 密钥有效且有足够的配额 +3. 更改环境变量后重启开发服务器 +4. 检查浏览器控制台和终端的具体错误消息 + +### 常见问题 + +- **配置无法保存**:检查 electron-store 权限 +- **API 认证错误**:确认 API 密钥正确且有适当权限 +- **模型不可用**:某些提供商可能有地区限制 +- **速率限制**:你可能超过了 API 提供商的速率限制 diff --git a/electron/main/index.ts b/electron/main/index.ts index 21dea52..c495567 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -3,7 +3,6 @@ import { app, BrowserWindow, dialog, - ipcMain, WebContentsView, protocol, } from "electron"; @@ -27,9 +26,9 @@ import { EkoService } from "./services/eko-service"; import { ServerManager } from "./services/server-manager"; import { MainWindowManager } from "./windows/main-window"; import { taskScheduler } from "./services/task-scheduler"; -import { taskWindowManager } from "./services/task-window-manager"; import { windowContextManager, type WindowContext } from "./services/window-context-manager"; import { cwd } from "node:process"; +import { registerAllIpcHandlers } from "./ipc"; Object.assign(console, log.functions); @@ -328,219 +327,8 @@ app.on("window-all-closed", () => { // Scheduled tasks will continue executing in background }); -ipcMain.handle('get-main-view-screenshot', async (event) => { - // Get corresponding detailView based on caller window - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.detailView) { - throw new Error('DetailView not found for this window'); - } - - const image = await context.detailView.webContents.capturePage() - return { - imageBase64: image.toDataURL(), - imageType: "image/jpeg", - } -}); +// Register all IPC handlers +registerAllIpcHandlers(); reloadOnChange(); // setupAutoUpdater(); - -// EkoService IPC handlers - supports window isolation -ipcMain.handle('eko:run', async (event, message: string) => { - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.ekoService) { - throw new Error('EkoService not found for this window'); - } - return await context.ekoService.run(message); -}); - -ipcMain.handle('eko:modify', async (event, taskId: string, message: string) => { - try { - console.log('IPC eko:modify received:', taskId, message); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.ekoService) { - throw new Error('EkoService not found for this window'); - } - return await context.ekoService.modify(taskId, message); - } catch (error: any) { - console.error('IPC eko:modify error:', error); - throw error; - } -}); - -ipcMain.handle('eko:execute', async (event, taskId: string) => { - try { - console.log('IPC eko:execute received:', taskId); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.ekoService) { - throw new Error('EkoService not found for this window'); - } - return await context.ekoService.execute(taskId); - } catch (error: any) { - console.error('IPC eko:execute error:', error); - throw error; - } -}); - -ipcMain.handle('eko:getTaskStatus', async (event, taskId: string) => { - try { - console.log('IPC eko:getTaskStatus received:', taskId); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.ekoService) { - throw new Error('EkoService not found for this window'); - } - return await context.ekoService.getTaskStatus(taskId); - } catch (error: any) { - console.error('IPC eko:getTaskStatus error:', error); - throw error; - } -}); - -ipcMain.handle('eko:cancel-task', async (event, taskId: string) => { - try { - console.log('IPC eko:cancel-task received:', taskId); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.ekoService) { - throw new Error('EkoService not found for this window'); - } - const result = await context.ekoService.cancleTask(taskId); - return { success: true, result }; - } catch (error: any) { - console.error('IPC eko:cancel-task error:', error); - throw error; - } -}); - -// IPC handler for controlling detail view visibility - supports window isolation -ipcMain.handle('set-detail-view-visible', async (event, visible: boolean) => { - try { - console.log('IPC set-detail-view-visible received:', visible); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.detailView) { - throw new Error('DetailView not found for this window'); - } - - context.detailView.setVisible(visible); - - return { success: true, visible }; - } catch (error: any) { - console.error('IPC set-detail-view-visible error:', error); - throw error; - } -}); - -// URL-related IPC handlers - supports window isolation -ipcMain.handle('get-current-url', async (event) => { - try { - console.log('IPC get-current-url received'); - const context = windowContextManager.getContext(event.sender.id); - if (!context || !context.detailView) { - return ''; - } - return context.detailView.webContents.getURL(); - } catch (error: any) { - console.error('IPC get-current-url error:', error); - return ''; - } -}); - -// History view management IPC handlers - supports window isolation -ipcMain.handle('show-history-view', async (event, screenshot: string) => { - try { - console.log('IPC show-history-view received'); - const context = windowContextManager.getContext(event.sender.id); - if (!context) { - throw new Error('Window context not found'); - } - - // Create history view - if (context.historyView) { - context.window.contentView.removeChildView(context.historyView); - } - - context.historyView = new WebContentsView(); - - // Load screenshot content - const htmlContent = ` - - - - - - Historical screenshot - - - `; - - await context.historyView.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`); - - // Set history view position (overlay detail panel position) - context.window.contentView.addChildView(context.historyView); - context.historyView.setBounds({ - x: 818, - y: 264, - width: 748, - height: 560, - }); - - return { success: true }; - } catch (error: any) { - console.error('IPC show-history-view error:', error); - throw error; - } -}); - -ipcMain.handle('hide-history-view', async (event) => { - try { - console.log('IPC hide-history-view received'); - const context = windowContextManager.getContext(event.sender.id); - if (context && context.historyView) { - context.window.contentView.removeChildView(context.historyView); - context.historyView = null; - } - return { success: true }; - } catch (error: any) { - console.error('IPC hide-history-view error:', error); - throw error; - } -}); - -// IPC handler for opening task window history panel -ipcMain.handle('open-task-history', async (_event, taskId: string) => { - try { - console.log('[IPC] open-task-history received:', taskId); - - // Check if task window already exists - let taskWindow = taskWindowManager.getTaskWindow(taskId); - - if (taskWindow) { - // Window exists, activate it - console.log('[IPC] Task window exists, activating window'); - taskWindow.window.show(); - taskWindow.window.focus(); - } else { - // Window doesn't exist, create new window - console.log('[IPC] Task window does not exist, creating new window'); - - // Generate new executionId (for creating window, won't execute task immediately) - const executionId = `view_history_${Date.now()}`; - - // Create task window - taskWindow = await taskWindowManager.createTaskWindow(taskId, executionId); - } - - // Wait for window content to load, then send open history panel event - setTimeout(() => { - taskWindow!.window.webContents.send('open-history-panel', { taskId }); - console.log('[IPC] Sent open-history-panel event to task window'); - }, 1000); // Delay 1 second to ensure page is loaded - - return { success: true }; - } catch (error: any) { - console.error('[IPC] open-task-history error:', error); - throw error; - } -}); diff --git a/electron/main/ipc/config-handlers.ts b/electron/main/ipc/config-handlers.ts new file mode 100644 index 0000000..3fc678d --- /dev/null +++ b/electron/main/ipc/config-handlers.ts @@ -0,0 +1,96 @@ +import { ipcMain } from "electron"; +import { ConfigManager, type UserModelConfigs, type ProviderType } from "../utils/config-manager"; +import { windowContextManager } from "../services/window-context-manager"; + +/** + * Register all configuration-related IPC handlers + */ +export function registerConfigHandlers() { + // Get user model configurations + ipcMain.handle('config:get-user-configs', async () => { + try { + const configManager = ConfigManager.getInstance(); + return configManager.getUserModelConfigs(); + } catch (error: any) { + console.error('IPC config:get-user-configs error:', error); + throw error; + } + }); + + // Save user model configurations + ipcMain.handle('config:save-user-configs', async (_event, configs: UserModelConfigs) => { + try { + const configManager = ConfigManager.getInstance(); + configManager.saveUserModelConfigs(configs); + + // Reload EkoService configuration for all windows + const contexts = windowContextManager.getAllContexts(); + contexts.forEach(context => { + if (context.ekoService) { + context.ekoService.reloadConfig(); + } + }); + + return { success: true }; + } catch (error: any) { + console.error('IPC config:save-user-configs error:', error); + throw error; + } + }); + + // Get model configuration for specific provider + ipcMain.handle('config:get-model-config', async (_event, provider: ProviderType) => { + try { + const configManager = ConfigManager.getInstance(); + return configManager.getModelConfig(provider); + } catch (error: any) { + console.error('IPC config:get-model-config error:', error); + throw error; + } + }); + + // Get API key source (user/env/none) + ipcMain.handle('config:get-api-key-source', async (_event, provider: ProviderType) => { + try { + const configManager = ConfigManager.getInstance(); + return configManager.getApiKeySource(provider); + } catch (error: any) { + console.error('IPC config:get-api-key-source error:', error); + throw error; + } + }); + + // Get selected provider + ipcMain.handle('config:get-selected-provider', async () => { + try { + const configManager = ConfigManager.getInstance(); + return configManager.getSelectedProvider(); + } catch (error: any) { + console.error('IPC config:get-selected-provider error:', error); + throw error; + } + }); + + // Set selected provider + ipcMain.handle('config:set-selected-provider', async (_event, provider: ProviderType) => { + try { + const configManager = ConfigManager.getInstance(); + configManager.setSelectedProvider(provider); + + // Reload EkoService configuration for all windows + const contexts = windowContextManager.getAllContexts(); + contexts.forEach(context => { + if (context.ekoService) { + context.ekoService.reloadConfig(); + } + }); + + return { success: true }; + } catch (error: any) { + console.error('IPC config:set-selected-provider error:', error); + throw error; + } + }); + + console.log('[IPC] Configuration handlers registered'); +} diff --git a/electron/main/ipc/eko-handlers.ts b/electron/main/ipc/eko-handlers.ts new file mode 100644 index 0000000..45c7c00 --- /dev/null +++ b/electron/main/ipc/eko-handlers.ts @@ -0,0 +1,80 @@ +import { ipcMain } from "electron"; +import { windowContextManager } from "../services/window-context-manager"; + +/** + * Register all Eko service related IPC handlers + * All handlers support window isolation through windowContextManager + */ +export function registerEkoHandlers() { + // Run new task + ipcMain.handle('eko:run', async (event, message: string) => { + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.ekoService) { + throw new Error('EkoService not found for this window'); + } + return await context.ekoService.run(message); + }); + + // Modify existing task + ipcMain.handle('eko:modify', async (event, taskId: string, message: string) => { + try { + console.log('IPC eko:modify received:', taskId, message); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.ekoService) { + throw new Error('EkoService not found for this window'); + } + return await context.ekoService.modify(taskId, message); + } catch (error: any) { + console.error('IPC eko:modify error:', error); + throw error; + } + }); + + // Execute task + ipcMain.handle('eko:execute', async (event, taskId: string) => { + try { + console.log('IPC eko:execute received:', taskId); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.ekoService) { + throw new Error('EkoService not found for this window'); + } + return await context.ekoService.execute(taskId); + } catch (error: any) { + console.error('IPC eko:execute error:', error); + throw error; + } + }); + + // Get task status + ipcMain.handle('eko:getTaskStatus', async (event, taskId: string) => { + try { + console.log('IPC eko:getTaskStatus received:', taskId); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.ekoService) { + throw new Error('EkoService not found for this window'); + } + return await context.ekoService.getTaskStatus(taskId); + } catch (error: any) { + console.error('IPC eko:getTaskStatus error:', error); + throw error; + } + }); + + // Cancel task + ipcMain.handle('eko:cancel-task', async (event, taskId: string) => { + try { + console.log('IPC eko:cancel-task received:', taskId); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.ekoService) { + throw new Error('EkoService not found for this window'); + } + const result = await context.ekoService.cancleTask(taskId); + return { success: true, result }; + } catch (error: any) { + console.error('IPC eko:cancel-task error:', error); + throw error; + } + }); + + console.log('[IPC] Eko service handlers registered'); +} diff --git a/electron/main/ipc/history-handlers.ts b/electron/main/ipc/history-handlers.ts new file mode 100644 index 0000000..6eb68bb --- /dev/null +++ b/electron/main/ipc/history-handlers.ts @@ -0,0 +1,114 @@ +import { ipcMain, WebContentsView } from "electron"; +import { windowContextManager } from "../services/window-context-manager"; +import { taskWindowManager } from "../services/task-window-manager"; + +/** + * Register all history related IPC handlers + * Handles history view display and task history window management + * All handlers support window isolation through windowContextManager + */ +export function registerHistoryHandlers() { + // Show history view with screenshot + ipcMain.handle('show-history-view', async (event, screenshot: string) => { + try { + console.log('IPC show-history-view received'); + const context = windowContextManager.getContext(event.sender.id); + if (!context) { + throw new Error('Window context not found'); + } + + // Create history view + if (context.historyView) { + context.window.contentView.removeChildView(context.historyView); + } + + context.historyView = new WebContentsView(); + + // Load screenshot content + const htmlContent = ` + + + + + + Historical screenshot + + + `; + + await context.historyView.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`); + + // Set history view position (overlay detail panel position) + context.window.contentView.addChildView(context.historyView); + context.historyView.setBounds({ + x: 818, + y: 264, + width: 748, + height: 560, + }); + + return { success: true }; + } catch (error: any) { + console.error('IPC show-history-view error:', error); + throw error; + } + }); + + // Hide history view + ipcMain.handle('hide-history-view', async (event) => { + try { + console.log('IPC hide-history-view received'); + const context = windowContextManager.getContext(event.sender.id); + if (context && context.historyView) { + context.window.contentView.removeChildView(context.historyView); + context.historyView = null; + } + return { success: true }; + } catch (error: any) { + console.error('IPC hide-history-view error:', error); + throw error; + } + }); + + // Open task history window + ipcMain.handle('open-task-history', async (_event, taskId: string) => { + try { + console.log('[IPC] open-task-history received:', taskId); + + // Check if task window already exists + let taskWindow = taskWindowManager.getTaskWindow(taskId); + + if (taskWindow) { + // Window exists, activate it + console.log('[IPC] Task window exists, activating window'); + taskWindow.window.show(); + taskWindow.window.focus(); + } else { + // Window doesn't exist, create new window + console.log('[IPC] Task window does not exist, creating new window'); + + // Generate new executionId (for creating window, won't execute task immediately) + const executionId = `view_history_${Date.now()}`; + + // Create task window + taskWindow = await taskWindowManager.createTaskWindow(taskId, executionId); + } + + // Wait for window content to load, then send open history panel event + setTimeout(() => { + taskWindow!.window.webContents.send('open-history-panel', { taskId }); + console.log('[IPC] Sent open-history-panel event to task window'); + }, 1000); // Delay 1 second to ensure page is loaded + + return { success: true }; + } catch (error: any) { + console.error('[IPC] open-task-history error:', error); + throw error; + } + }); + + console.log('[IPC] History handlers registered'); +} diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts new file mode 100644 index 0000000..11aad7e --- /dev/null +++ b/electron/main/ipc/index.ts @@ -0,0 +1,25 @@ +import { registerEkoHandlers } from "./eko-handlers"; +import { registerViewHandlers } from "./view-handlers"; +import { registerHistoryHandlers } from "./history-handlers"; +import { registerConfigHandlers } from "./config-handlers"; + +/** + * Register all IPC handlers + * Centralized registration point for all IPC communication handlers + */ +export function registerAllIpcHandlers() { + registerEkoHandlers(); + registerViewHandlers(); + registerHistoryHandlers(); + registerConfigHandlers(); + + console.log('[IPC] All IPC handlers registered successfully'); +} + +// Export individual registration functions for selective use if needed +export { + registerEkoHandlers, + registerViewHandlers, + registerHistoryHandlers, + registerConfigHandlers +}; diff --git a/electron/main/ipc/view-handlers.ts b/electron/main/ipc/view-handlers.ts new file mode 100644 index 0000000..d4372d4 --- /dev/null +++ b/electron/main/ipc/view-handlers.ts @@ -0,0 +1,58 @@ +import { ipcMain } from "electron"; +import { windowContextManager } from "../services/window-context-manager"; + +/** + * Register all view control related IPC handlers + * Handles screenshot, visibility control, and URL operations + * All handlers support window isolation through windowContextManager + */ +export function registerViewHandlers() { + // Get main view screenshot + ipcMain.handle('get-main-view-screenshot', async (event) => { + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.detailView) { + throw new Error('DetailView not found for this window'); + } + + const image = await context.detailView.webContents.capturePage(); + return { + imageBase64: image.toDataURL(), + imageType: "image/jpeg", + }; + }); + + // Set detail view visibility + ipcMain.handle('set-detail-view-visible', async (event, visible: boolean) => { + try { + console.log('IPC set-detail-view-visible received:', visible); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.detailView) { + throw new Error('DetailView not found for this window'); + } + + context.detailView.setVisible(visible); + + return { success: true, visible }; + } catch (error: any) { + console.error('IPC set-detail-view-visible error:', error); + throw error; + } + }); + + // Get current URL from detail view + ipcMain.handle('get-current-url', async (event) => { + try { + console.log('IPC get-current-url received'); + const context = windowContextManager.getContext(event.sender.id); + if (!context || !context.detailView) { + return ''; + } + return context.detailView.webContents.getURL(); + } catch (error: any) { + console.error('IPC get-current-url error:', error); + return ''; + } + }); + + console.log('[IPC] View control handlers registered'); +} diff --git a/electron/main/services/eko-service.ts b/electron/main/services/eko-service.ts index e5ffe70..ccdb5b0 100644 --- a/electron/main/services/eko-service.ts +++ b/electron/main/services/eko-service.ts @@ -3,11 +3,14 @@ import { BrowserAgent, FileAgent } from "@jarvis-agent/electron"; import type { EkoResult } from "@jarvis-agent/core/types"; import { BrowserWindow, WebContentsView, app } from "electron"; import path from "node:path"; +import { ConfigManager } from "../utils/config-manager"; export class EkoService { private eko: Eko | null = null; private mainWindow: BrowserWindow; private detailView: WebContentsView; + private mcpClient!: SimpleSseMcpClient; + private agents!: any[]; constructor(mainWindow: BrowserWindow, detailView: WebContentsView) { this.mainWindow = mainWindow; @@ -15,79 +18,11 @@ export class EkoService { this.initializeEko(); } - private initializeEko() { - console.log(process.env) - // LLMs configuration - read from environment variables - const llms: LLMs = { - default: { - provider: "deepseek", - model: "deepseek-chat", - apiKey: process.env.DEEPSEEK_API_KEY || "", - config: { - baseURL: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com/v1", - maxTokens: 8192, - mode: 'regular', - }, - - fetch: (url, options) => { - // Intercept request and add thinking parameter - const body = JSON.parse((options?.body as string) || '{}'); - body.thinking = { type: "disabled" }; - - Log.info('Deepseek request options:\n', body); - - return fetch(url, { - ...options, - body: JSON.stringify(body) - }); - } - }, - "qwen-vl": { - provider: "openai", - model: "qwen-vl-max-2025-08-13", - apiKey: process.env.BAILIAN_API_KEY || "", // Use Bailian API key from environment - config: { - baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", - maxTokens: 16000, - timeout: 60000, - temperature: 0.7 - }, - fetch: (url, options) => { - Log.info('Vision model request parameters', options) - return fetch(url, options); - } - }, - 'open-router': { - provider: "openrouter", - model: "openai/gpt-5-mini", - apiKey: process.env.OPENROUTER_API_KEY || "", - config: { - // baseURL: "https://openai-proxy.awsv.cn/v1", - }, - }, - }; - - // Get correct application path - const appPath = app.isPackaged - ? path.join(app.getPath('userData'), 'static') // Packaged path - : path.join(process.cwd(), 'public', 'static'); // Development environment path - - Log.info(`FileAgent working path: ${appPath}`); - - // MCP client configuration - configure based on your MCP server address - const sseUrl = "http://localhost:5173/api/mcp/sse"; - let mcpClient = new SimpleSseMcpClient(sseUrl); - - const echartMcpUrl = "http://localhost:3033/sse"; - const echartMcpClient = new SimpleSseMcpClient(echartMcpUrl); - - - - // Create agents - can now use FileAgent since we're in Node.js environment - const agents = [new BrowserAgent(this.detailView, mcpClient), new FileAgent(this.detailView, appPath)]; - - // Stream callback handler - const callback = { + /** + * Create stream callback handler + */ + private createCallback() { + return { onMessage: (message: StreamCallbackMessage): Promise => { Log.info('EkoService stream callback:', message); @@ -143,10 +78,74 @@ export class EkoService { console.log('EkoService human callback:', message); } }; + } + + private initializeEko() { + // Get LLMs configuration from ConfigManager + // Priority: user config > env > default + const configManager = ConfigManager.getInstance(); + const llms: LLMs = configManager.getLLMsConfig(); + + // Get correct application path + const appPath = app.isPackaged + ? path.join(app.getPath('userData'), 'static') // Packaged path + : path.join(process.cwd(), 'public', 'static'); // Development environment path + + Log.info(`FileAgent working path: ${appPath}`); - // Initialize Eko instance - this.eko = new Eko({ llms, agents, callback }); - console.log('EkoService initialized with FileAgent support'); + // MCP client configuration - configure based on your MCP server address + const sseUrl = "http://localhost:5173/api/mcp/sse"; + this.mcpClient = new SimpleSseMcpClient(sseUrl); + + // Create agents - can now use FileAgent since we're in Node.js environment + this.agents = [new BrowserAgent(this.detailView, this.mcpClient), new FileAgent(this.detailView, appPath)]; + + // Create callback and initialize Eko instance + const callback = this.createCallback(); + this.eko = new Eko({ llms, agents: this.agents, callback }); + Log.info('EkoService initialized with LLMs:', llms.default?.model); + } + + /** + * Reload LLM configuration and reinitialize Eko instance + * Called when user changes model configuration in UI + */ + public reloadConfig(): void { + Log.info('Reloading EkoService configuration...'); + + // Abort all running tasks before reloading + if (this.eko) { + const allTaskIds = this.eko.getAllTaskId(); + allTaskIds.forEach(taskId => { + try { + this.eko!.abortTask(taskId, 'config-reload'); + } catch (error) { + Log.error(`Failed to abort task ${taskId}:`, error); + } + }); + } + + // Get new LLMs configuration + const configManager = ConfigManager.getInstance(); + const llms: LLMs = configManager.getLLMsConfig(); + + Log.info('New LLMs config:', llms.default?.model); + + // Create new Eko instance with updated config and fresh callback + const callback = this.createCallback(); + this.eko = new Eko({ llms, agents: this.agents, callback }); + + Log.info('EkoService configuration reloaded successfully'); + + // Notify frontend about config reload + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return; + } + + this.mainWindow.webContents.send('eko-config-reloaded', { + model: llms.default?.model, + provider: llms.default?.provider + }); } /** @@ -154,32 +153,62 @@ export class EkoService { */ async run(message: string): Promise { if (!this.eko) { - throw new Error('Eko service not initialized'); + const errorMsg = 'Eko service not initialized'; + Log.error(errorMsg); + this.sendErrorToFrontend(errorMsg); + return null; } - + console.log('EkoService running task:', message); let result = null; try { result = await this.eko.run(message); - } catch (error) { + } catch (error: any) { Log.error('EkoService run error:', error); + + // Extract error message + const errorMessage = error?.message || error?.toString() || 'Unknown error occurred'; + this.sendErrorToFrontend(errorMessage, error); } return result; } + /** + * Send error message to frontend + */ + private sendErrorToFrontend(errorMessage: string, error?: any, taskId?: string): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + Log.warn('Main window destroyed, cannot send error message'); + return; + } + + this.mainWindow.webContents.send('eko-stream-message', { + type: 'error', + error: errorMessage, + detail: error?.stack || error?.toString() || errorMessage, + taskId: taskId // Include taskId if available + }); + } + /** * Modify existing task */ async modify(taskId: string, message: string): Promise { if (!this.eko) { - throw new Error('Eko service not initialized'); + const errorMsg = 'Eko service not initialized'; + Log.error(errorMsg); + this.sendErrorToFrontend(errorMsg, undefined, taskId); + return null; } + let result = null; try { await this.eko.modify(taskId, message); result = await this.eko.execute(taskId); - } catch (error) { + } catch (error: any) { Log.error('EkoService modify error:', error); + const errorMessage = error?.message || error?.toString() || 'Failed to modify task'; + this.sendErrorToFrontend(errorMessage, error, taskId); } return result; } @@ -187,13 +216,23 @@ export class EkoService { /** * Execute task */ - async execute(taskId: string): Promise { + async execute(taskId: string): Promise { if (!this.eko) { - throw new Error('Eko service not initialized'); + const errorMsg = 'Eko service not initialized'; + Log.error(errorMsg); + this.sendErrorToFrontend(errorMsg, undefined, taskId); + return null; } console.log('EkoService executing task:', taskId); - return await this.eko.execute(taskId); + try { + return await this.eko.execute(taskId); + } catch (error: any) { + Log.error('EkoService execute error:', error); + const errorMessage = error?.message || error?.toString() || 'Failed to execute task'; + this.sendErrorToFrontend(errorMessage, error, taskId); + return null; + } } /** diff --git a/electron/main/utils/config-manager.ts b/electron/main/utils/config-manager.ts index 271feee..97f7133 100644 --- a/electron/main/utils/config-manager.ts +++ b/electron/main/utils/config-manager.ts @@ -2,6 +2,50 @@ import { config } from "dotenv"; import path from "node:path"; import { app } from "electron"; import fs from "fs"; +import { store } from "./store"; + +/** + * Supported providers + */ +export type ProviderType = 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter'; + +/** + * Model configuration interface + */ +export interface ModelConfig { + provider: string; + model: string; + apiKey?: string; + baseURL?: string; +} + +/** + * User model configurations stored in electron-store + */ +export interface UserModelConfigs { + deepseek?: { + apiKey?: string; + baseURL?: string; + model?: string; + }; + qwen?: { + apiKey?: string; + model?: string; + }; + google?: { + apiKey?: string; + model?: string; + }; + anthropic?: { + apiKey?: string; + model?: string; + }; + openrouter?: { + apiKey?: string; + model?: string; + }; + selectedProvider?: ProviderType; +} /** * Configuration Manager for handling environment variables in both development and production @@ -95,4 +139,261 @@ export class ConfigManager { console.warn('[ConfigManager] Missing required API keys:', validation.missingKeys); } } + + /** + * Get user model configurations from electron-store + */ + public getUserModelConfigs(): UserModelConfigs { + return store.get('modelConfigs', {}) as UserModelConfigs; + } + + /** + * Save user model configurations to electron-store + */ + public saveUserModelConfigs(configs: UserModelConfigs): void { + store.set('modelConfigs', configs); + console.log('[ConfigManager] User model configurations saved'); + } + + /** + * Get final model configuration with priority: user config > env > default + */ + public getModelConfig(provider: ProviderType): ModelConfig | null { + const userConfigs = this.getUserModelConfigs(); + + switch (provider) { + case 'deepseek': + return { + provider: 'deepseek', + model: userConfigs.deepseek?.model || 'deepseek-chat', + apiKey: userConfigs.deepseek?.apiKey || process.env.DEEPSEEK_API_KEY || '', + baseURL: userConfigs.deepseek?.baseURL || process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1' + }; + + case 'qwen': + return { + provider: 'openai', + model: userConfigs.qwen?.model || 'qwen-max', + apiKey: userConfigs.qwen?.apiKey || process.env.QWEN_API_KEY || '', + baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' + }; + + case 'google': + return { + provider: 'google', + model: userConfigs.google?.model || 'gemini-1.5-flash-latest', + apiKey: userConfigs.google?.apiKey || process.env.GOOGLE_API_KEY || '' + }; + + case 'anthropic': + return { + provider: 'anthropic', + model: userConfigs.anthropic?.model || 'claude-3-5-sonnet-latest', + apiKey: userConfigs.anthropic?.apiKey || process.env.ANTHROPIC_API_KEY || '' + }; + + case 'openrouter': + return { + provider: 'openrouter', + model: userConfigs.openrouter?.model || 'anthropic/claude-3.5-sonnet', + apiKey: userConfigs.openrouter?.apiKey || process.env.OPENROUTER_API_KEY || '' + }; + + default: + return null; + } + } + + /** + * Get API key source info (for UI display) + */ + public getApiKeySource(provider: ProviderType): 'user' | 'env' | 'none' { + const userConfigs = this.getUserModelConfigs(); + + // Check user config first (highest priority) + if (userConfigs[provider]?.apiKey) { + return 'user'; + } + + // Then check environment variables + const envKeys: Record = { + deepseek: 'DEEPSEEK_API_KEY', + qwen: 'QWEN_API_KEY', + google: 'GOOGLE_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', + openrouter: 'OPENROUTER_API_KEY' + }; + + const envKey = envKeys[provider]; + if (process.env[envKey]) { + return 'env'; + } + + return 'none'; + } + + /** + * Get selected provider (with fallback) + */ + public getSelectedProvider(): ProviderType { + const userConfigs = this.getUserModelConfigs(); + return userConfigs.selectedProvider || 'deepseek'; + } + + /** + * Set selected provider + */ + public setSelectedProvider(provider: ProviderType): void { + const userConfigs = this.getUserModelConfigs(); + userConfigs.selectedProvider = provider; + this.saveUserModelConfigs(userConfigs); + } + + /** + * Get maxTokens for specific model + */ + private getMaxTokensForModel(provider: ProviderType, model: string): number { + // Define maxTokens for different models + const tokenLimits: Record = { + // Deepseek + 'deepseek-chat': 8192, + 'deepseek-reasoner': 65536, + + // Google + 'gemini-2.0-flash-thinking-exp-01-21': 65536, + 'gemini-1.5-flash-latest': 8192, + 'gemini-2.0-flash-exp': 8192, + 'gemini-1.5-flash-002': 8192, + 'gemini-1.5-flash-8b': 8192, + 'gemini-1.5-pro-latest': 8192, + 'gemini-1.5-pro-002': 8192, + 'gemini-exp-1206': 8192, + + // Anthropic + 'claude-3-7-sonnet-20250219': 128000, + 'claude-3-5-sonnet-latest': 8000, + 'claude-3-5-sonnet-20240620': 8000, + 'claude-3-5-haiku-latest': 8000, + 'claude-3-opus-latest': 8000, + 'claude-3-sonnet-20240229': 8000, + 'claude-3-haiku-20240307': 8000, + + // Qwen + 'qwen-max': 8192, + 'qwen-plus': 8192, + 'qwen-vl-max': 8192, + }; + + // Return specific token limit or default based on provider + return tokenLimits[model] || (provider === 'openrouter' ? 8000 : 8192); + } + + /** + * Get LLMs configuration for Eko framework + * Returns configured LLMs object based on selected provider + */ + public getLLMsConfig(): any { + const selectedProvider = this.getSelectedProvider(); + const providerConfig = this.getModelConfig(selectedProvider); + + if (!providerConfig) { + console.error(`[ConfigManager] No config found for provider: ${selectedProvider}`); + return { default: null }; + } + + const logInfo = (msg: string, ...args: any[]) => console.log(`[ConfigManager] ${msg}`, ...args); + const maxTokens = this.getMaxTokensForModel(selectedProvider, providerConfig.model); + + // Build default LLM based on selected provider + let defaultLLM: any; + + switch (selectedProvider) { + case 'deepseek': + defaultLLM = { + provider: providerConfig.provider, + model: providerConfig.model, + apiKey: providerConfig.apiKey || "", + config: { + baseURL: providerConfig.baseURL || "https://api.deepseek.com/v1", + maxTokens, + mode: 'regular', + }, + fetch: (url: string, options?: any) => { + // Intercept request and add thinking parameter for deepseek + const body = JSON.parse((options?.body as string) || '{}'); + body.thinking = { type: "disabled" }; + logInfo('Deepseek request:', providerConfig.model); + return fetch(url, { + ...options, + body: JSON.stringify(body) + }); + } + }; + break; + + case 'qwen': + defaultLLM = { + provider: providerConfig.provider, + model: providerConfig.model, + apiKey: providerConfig.apiKey || "", + config: { + baseURL: providerConfig.baseURL || "https://dashscope.aliyuncs.com/compatible-mode/v1", + maxTokens, + timeout: 60000, + temperature: 0.7 + }, + fetch: (url: string, options?: any) => { + logInfo('Qwen request:', providerConfig.model); + return fetch(url, options); + } + }; + break; + + case 'google': + defaultLLM = { + provider: providerConfig.provider, + model: providerConfig.model, + apiKey: providerConfig.apiKey || "", + config: { + maxTokens, + temperature: 0.7 + } + }; + break; + + case 'anthropic': + defaultLLM = { + provider: providerConfig.provider, + model: providerConfig.model, + apiKey: providerConfig.apiKey || "", + config: { + maxTokens, + temperature: 0.7 + } + }; + break; + + case 'openrouter': + defaultLLM = { + provider: providerConfig.provider, + model: providerConfig.model, + apiKey: providerConfig.apiKey || "", + config: { + maxTokens + } + }; + break; + + default: + console.error(`[ConfigManager] Unsupported provider: ${selectedProvider}`); + return { default: null }; + } + + logInfo(`Using provider: ${selectedProvider}, model: ${providerConfig.model}, maxTokens: ${maxTokens}`); + + // Return LLMs configuration + return { + default: defaultLLM, + }; + } } \ No newline at end of file diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts deleted file mode 100644 index 98fe44a..0000000 --- a/electron/preload/index.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ElectronAPI } from '@electron-toolkit/preload' - -declare global { - interface Window { - electron: ElectronAPI - api: { - getAppVersion: () => Promise - getPlatform: () => Promise - onNewTab: (callback: () => void) => void - onCloseTab: (callback: () => void) => void - onNavigateBack: (callback: () => void) => void - onNavigateForward: (callback: () => void) => void - onReloadPage: (callback: () => void) => void - onShowAbout: (callback: () => void) => void - removeAllListeners: (channel: string) => void - navigateTo: (url: string) => Promise - sendToMainViewExecuteCode: (func: string, args: any[]) => Promise - getMainViewSize: () => Promise<{ width: number; height: number }> - getMainViewUrlAndTitle: () => Promise<{ url: string; title: string }> - getMainViewScreenshot: () => Promise<{ imageBase64: string; imageType: "image/jpeg" | "image/png" }> - getHiddenWindowSourceId: () => Promise - - // Voice and TTS related APIs - sendVoiceTextToChat: (text: string) => Promise - onVoiceTextReceived: (callback: (text: string) => void) => void - sendTTSSubtitle: (text: string, isStart: boolean) => Promise - onTTSSubtitleReceived: (callback: (text: string, isStart: boolean) => void) => void - - // EkoService related APIs - ekoRun: (message: string) => Promise - ekoModify: (taskId: string, message: string) => Promise<{ success: boolean }> - ekoExecute: (taskId: string) => Promise - ekoGetTaskStatus: (taskId: string) => Promise - ekoCancelTask: (taskId: string) => Promise<{ success: boolean; result: any }> - onEkoStreamMessage: (callback: (message: any) => void) => void - - // File update related APIs (for view preload) - onFileUpdated: (callback: (status: string, content: string) => void) => void - - // Generic invoke method - invoke: (channel: string, ...args: any[]) => Promise - - // Scheduled task execution completion listener - onTaskExecutionComplete: (callback: (event: any) => void) => void - - // Open history panel listener - onOpenHistoryPanel: (callback: (event: any) => void) => void - - // Task aborted by system listener - onTaskAbortedBySystem: (callback: (event: any) => void) => void - } - process?: { - type: string - platform: string - versions: any - } - } -} \ No newline at end of file diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6468f00..2b08bf3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -40,6 +40,14 @@ const api = { ekoCancelTask: (taskId: string) => ipcRenderer.invoke('eko:cancel-task', taskId), onEkoStreamMessage: (callback: (message: any) => void) => ipcRenderer.on('eko-stream-message', (_, message) => callback(message)), + // Model configuration APIs + getUserModelConfigs: () => ipcRenderer.invoke('config:get-user-configs'), + saveUserModelConfigs: (configs: any) => ipcRenderer.invoke('config:save-user-configs', configs), + getModelConfig: (provider: 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter') => ipcRenderer.invoke('config:get-model-config', provider), + getApiKeySource: (provider: 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter') => ipcRenderer.invoke('config:get-api-key-source', provider), + getSelectedProvider: () => ipcRenderer.invoke('config:get-selected-provider'), + setSelectedProvider: (provider: 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter') => ipcRenderer.invoke('config:set-selected-provider', provider), + // Detail view control APIs setDetailViewVisible: (visible: boolean) => ipcRenderer.invoke('set-detail-view-visible', visible), // URL retrieval and monitoring APIs diff --git a/src/components/ModelConfigBar.tsx b/src/components/ModelConfigBar.tsx new file mode 100644 index 0000000..9a08b74 --- /dev/null +++ b/src/components/ModelConfigBar.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react'; +import { Select, Button, Input, App } from 'antd'; +import { EditOutlined, CheckOutlined, CloseOutlined, LinkOutlined } from '@ant-design/icons'; +import type { UserModelConfigs } from '@/type'; + +const { Option } = Select; + +// Provider options +const PROVIDERS = [ + { value: 'deepseek', label: 'Deepseek', getKeyUrl: 'https://platform.deepseek.com/api_keys' }, + { value: 'qwen', label: 'Qwen (Alibaba)', getKeyUrl: 'https://bailian.console.aliyun.com/' }, + { value: 'google', label: 'Google Gemini', getKeyUrl: 'https://aistudio.google.com/app/apikey' }, + { value: 'anthropic', label: 'Anthropic', getKeyUrl: 'https://console.anthropic.com/settings/keys' }, + { value: 'openrouter', label: 'OpenRouter', getKeyUrl: 'https://openrouter.ai/keys' }, +]; + +// Model options for each provider +const MODELS: Record = { + deepseek: [ + 'deepseek-chat', + 'deepseek-reasoner', + ], + google: [ + 'gemini-1.5-flash-latest', + 'gemini-2.0-flash-thinking-exp-01-21', + 'gemini-2.0-flash-exp', + 'gemini-1.5-flash-002', + 'gemini-1.5-flash-8b', + 'gemini-1.5-pro-latest', + 'gemini-1.5-pro-002', + 'gemini-exp-1206', + ], + openrouter: [ + 'anthropic/claude-3.5-sonnet', + 'anthropic/claude-3-haiku', + 'deepseek/deepseek-coder', + 'google/gemini-flash-1.5', + 'google/gemini-pro-1.5', + 'x-ai/grok-beta', + 'mistralai/mistral-nemo', + 'qwen/qwen-110b-chat', + 'cohere/command', + ], + anthropic: [ + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-latest', + 'claude-3-5-sonnet-20240620', + 'claude-3-5-haiku-latest', + 'claude-3-opus-latest', + 'claude-3-sonnet-20240229', + 'claude-3-haiku-20240307', + ], + qwen: [ + 'qwen-max', + 'qwen-plus', + 'qwen-vl-max', + ], +}; + +type ProviderType = 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter'; + +export const ModelConfigBar: React.FC = () => { + + const message = App.useApp().message; + + const [selectedProvider, setSelectedProvider] = useState('deepseek'); + const [selectedModel, setSelectedModel] = useState('deepseek-chat'); + const [apiKeySource, setApiKeySource] = useState<'user' | 'env' | 'none'>('none'); + const [configs, setConfigs] = useState({}); + const [isEditingApiKey, setIsEditingApiKey] = useState(false); + const [tempApiKey, setTempApiKey] = useState(''); + + // Load initial configurations + useEffect(() => { + loadConfigs(); + }, []); + + // Update model when provider changes + useEffect(() => { + const models = MODELS[selectedProvider]; + if (models && models.length > 0) { + const currentModel = configs[selectedProvider]?.model || models[0]; + setSelectedModel(currentModel); + } + }, [selectedProvider, configs]); + + const loadConfigs = async () => { + try { + const userConfigs = await window.api.getUserModelConfigs(); + const provider = await window.api.getSelectedProvider(); + const source = await window.api.getApiKeySource(provider); + + setConfigs(userConfigs); + setSelectedProvider(provider); + setApiKeySource(source); + } catch (error) { + console.error('Failed to load model configs:', error); + } + }; + + const handleProviderChange = async (value: ProviderType) => { + try { + setSelectedProvider(value); + await window.api.setSelectedProvider(value); + const source = await window.api.getApiKeySource(value); + setApiKeySource(source); + } catch (error) { + console.error('Failed to change provider:', error); + message.error('Failed to change provider'); + } + }; + + const handleModelChange = async (value: string) => { + try { + setSelectedModel(value); + const updatedConfigs = { + ...configs, + [selectedProvider]: { + ...configs[selectedProvider], + model: value, + }, + }; + await window.api.saveUserModelConfigs(updatedConfigs); + setConfigs(updatedConfigs); + message.success('Model updated'); + } catch (error) { + console.error('Failed to update model:', error); + message.error('Failed to update model'); + } + }; + + const handleEditApiKey = () => { + setIsEditingApiKey(true); + setTempApiKey(configs[selectedProvider]?.apiKey || ''); + }; + + const handleCancelEdit = () => { + setIsEditingApiKey(false); + setTempApiKey(''); + }; + + const handleSaveApiKey = async () => { + // Validate API Key is not empty + if (!tempApiKey || tempApiKey.trim() === '') { + message.warning('API Key cannot be empty'); + return; + } + + try { + const updatedConfigs = { + ...configs, + [selectedProvider]: { + ...configs[selectedProvider], + apiKey: tempApiKey.trim(), + }, + }; + await window.api.saveUserModelConfigs(updatedConfigs); + setConfigs(updatedConfigs); + setIsEditingApiKey(false); + setApiKeySource('user'); + message.success('API Key saved'); + } catch (error) { + console.error('Failed to save API key:', error); + message.error('Failed to save API Key'); + } + }; + + const currentProvider = PROVIDERS.find(p => p.value === selectedProvider); + + return ( +
+ {/* Provider and Model Selection */} +
+ + + +
+ + {/* API Key Section */} +
+
+ + {selectedProvider.charAt(0).toUpperCase() + selectedProvider.slice(1)} API Key: + + + {apiKeySource === 'env' && !isEditingApiKey && ( + + + Set via environment variable + + )} + + {apiKeySource === 'user' && !isEditingApiKey && ( + + + Set by user + + )} + + {apiKeySource === 'none' && !isEditingApiKey && ( + Not configured + )} + + {isEditingApiKey && ( + setTempApiKey(e.target.value)} + placeholder="Enter your API Key" + className="flex-1 max-w-sm" + size="small" + onPressEnter={handleSaveApiKey} + /> + )} +
+ +
+ {!isEditingApiKey ? ( + <> + + + + Get API Key + + + ) : ( + <> + + + + )} +
+
+
+ ); +}; diff --git a/src/components/chat/MessageComponents.tsx b/src/components/chat/MessageComponents.tsx index 2a05411..12d55ad 100644 --- a/src/components/chat/MessageComponents.tsx +++ b/src/components/chat/MessageComponents.tsx @@ -245,6 +245,10 @@ const AgentGroupDisplay = ({
{agentMessage.status === 'completed' ? ( + ) : agentMessage.status === 'error' ? ( +
+ +
) : ( )} @@ -262,7 +266,7 @@ const AgentGroupDisplay = ({ {/* Agent execution steps */} {!isCollapsed && (
- {agentMessage.messages.map((message, index) => { + {agentMessage.messages.map((message) => { return (
diff --git a/src/hooks/useTaskManager.ts b/src/hooks/useTaskManager.ts index 3d75c0b..5bb6cec 100644 --- a/src/hooks/useTaskManager.ts +++ b/src/hooks/useTaskManager.ts @@ -15,6 +15,7 @@ interface UseTaskManagerReturn { createTask: (taskId: string, initialData: Partial) => void; updateMessages: (taskId: string, messages: DisplayMessage[]) => void; addToolHistory: (taskId: string, toolData: any) => void; + replaceTaskId: (oldTaskId: string, newTaskId: string) => void; // History mode enterHistoryMode: (task: Task) => void; @@ -120,6 +121,43 @@ export const useTaskManager = (): UseTaskManagerReturn => { }); }, [saveTask]); + // Replace task ID (for temporary task -> real task transition) + const replaceTaskId = useCallback((oldTaskId: string, newTaskId: string) => { + if (isHistoryMode) return; + + setTasks(prevTasks => { + const existingTaskIndex = prevTasks.findIndex(task => task.id === oldTaskId); + if (existingTaskIndex >= 0) { + const updatedTasks = [...prevTasks]; + // Create new task object with new ID, keep all other data + const newTask = { + ...updatedTasks[existingTaskIndex], + id: newTaskId, + updatedAt: new Date() + }; + + // Replace old task with new task + updatedTasks[existingTaskIndex] = newTask; + + // Save new task to IndexedDB + saveTask(newTask); + + // Delete old temporary task from IndexedDB + taskStorage.deleteTask(oldTaskId).catch(error => { + console.error('Failed to delete temporary task:', error); + }); + + return updatedTasks; + } + return prevTasks; + }); + + // Update currentTaskId if it matches the old ID + if (currentTaskId === oldTaskId) { + setCurrentTaskId(newTaskId); + } + }, [isHistoryMode, saveTask, currentTaskId]); + // Enter history mode const enterHistoryMode = useCallback((task: Task) => { setIsHistoryMode(true); @@ -153,6 +191,7 @@ export const useTaskManager = (): UseTaskManagerReturn => { createTask, updateMessages, addToolHistory, + replaceTaskId, enterHistoryMode, exitHistoryMode, diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 9299b59..281088e 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -5,6 +5,7 @@ import { Input, Button } from 'antd' import {ImageType, ReportType, SlideType, WebType, SpreadsheetType, VisualizeType, MoreType} from '@/icons/source-type-icons' import { ScheduledTaskModal, ScheduledTaskListPanel } from '@/components/scheduled-task' import { useScheduledTaskStore } from '@/stores/scheduled-task-store' +import { ModelConfigBar } from '@/components/ModelConfigBar' export default function Home() { const [query, setQuery] = useState('') @@ -79,16 +80,28 @@ export default function Home() {

I am Jarvis, a robot powered by llm. What can I do for you?

- {/* Query input box */} -
- setQuery(e.target.value)} - onKeyDown={handleKeyDown} - className='!h-full !bg-tool-call !text-text-01-dark !placeholder-text-12-dark !py-3 !px-4' - placeholder='Please enter your task' - autoSize={false} - /> + {/* Unified Input Area: Model Config + Query Input */} +
+
+ {/* Model Configuration Bar */} + + + {/* Query input box */} +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className='!h-full !bg-transparent !text-text-01-dark !placeholder-text-12-dark !py-3 !px-4 !border !border-solid' + placeholder='Please enter your task' + autoSize={false} + style={{ + borderColor: 'rgba(255, 255, 255, 0.2)', + borderWidth: '1px', + }} + /> +
+
diff --git a/src/pages/main.tsx b/src/pages/main.tsx index 4e0f54d..fc5704c 100644 --- a/src/pages/main.tsx +++ b/src/pages/main.tsx @@ -34,6 +34,7 @@ export default function main() { createTask, updateMessages, addToolHistory, + replaceTaskId, enterHistoryMode, } = useTaskManager(); @@ -349,47 +350,60 @@ export default function main() { const updatedMessages = messageProcessorRef.current.processStreamMessage(message); console.log('Updated message list:', updatedMessages); - // Set task ID (if not already set) - if (message.taskId && !currentTaskId) { + // Handle task ID replacement: temporary task -> real task + const isCurrentTaskTemporary = taskIdRef.current?.startsWith('temp-'); + const hasRealTaskId = message.taskId && !message.taskId.startsWith('temp-'); + + if (isCurrentTaskTemporary && hasRealTaskId) { + const tempTaskId = taskIdRef.current; + const realTaskId = message.taskId; + + console.log(`Replacing temporary task ${tempTaskId} with real task ${realTaskId}`); + + // Replace task ID + replaceTaskId(tempTaskId, realTaskId); + + // Update taskIdRef + taskIdRef.current = realTaskId; + + // Update task with new workflow info if available + if (message.type === 'workflow' && message.workflow?.name) { + updateTask(realTaskId, { + name: message.workflow.name, + workflow: message.workflow, + messages: updatedMessages + }); + } else { + updateTask(realTaskId, { messages: updatedMessages }); + } + + return; // Exit early, task ID has been replaced + } + + // Set task ID (if not already set and not temporary) + if (message.taskId && !currentTaskId && !message.taskId.startsWith('temp-')) { setCurrentTaskId(message.taskId); } // Update or create task const taskIdToUpdate = message.taskId || taskIdRef.current; if (taskIdToUpdate) { - const existingTask = tasks.find(task => task.id === taskIdToUpdate); - - if (existingTask) { - // Update existing task - const updates: Partial = { - messages: updatedMessages - }; + const updates: Partial = { + messages: updatedMessages + }; - if (message.type === 'workflow' && message.workflow?.name) { - updates.name = message.workflow.name; - updates.workflow = message.workflow; - } + if (message.type === 'workflow' && message.workflow?.name) { + updates.name = message.workflow.name; + updates.workflow = message.workflow; + } - updateTask(taskIdToUpdate, updates); - } else { - // Create new task - const initialData: Partial = { - name: (message.type === 'workflow' && message.workflow?.name) - ? message.workflow.name - : `Task ${taskIdToUpdate.slice(0, 8)}`, - workflow: (message.type === 'workflow' && message.workflow) - ? message.workflow - : undefined, - messages: updatedMessages, - status: 'running', // Set to running state on initialization - // Set taskType based on whether it's scheduled task mode - taskType: isTaskDetailMode ? 'scheduled' : 'normal', - scheduledTaskId: isTaskDetailMode ? scheduledTaskIdFromUrl : undefined, - startTime: new Date(), // Record start time - }; - - createTask(taskIdToUpdate, initialData); + // For error messages, also update task status + if (message.type === 'error') { + updates.status = 'error'; } + + // Always update task (will only work if task exists) + updateTask(taskIdToUpdate, updates); } // Detect tool call messages, automatically show detail panel @@ -604,8 +618,23 @@ export default function main() { // Add user message to message processor const updatedMessages = messageProcessorRef.current.addUserMessage(message.trim()); - // Immediately update current task's messages (if task exists) - if (taskIdRef.current) { + // If no current task, create temporary task immediately to display user message + if (!taskIdRef.current) { + const tempTaskId = `temp-${newExecutionId}`; + taskIdRef.current = tempTaskId; + setCurrentTaskId(tempTaskId); + + // Create temporary task with user message + createTask(tempTaskId, { + name: 'Processing...', + messages: updatedMessages, + status: 'running', + taskType: isTaskDetailMode ? 'scheduled' : 'normal', + scheduledTaskId: isTaskDetailMode ? scheduledTaskIdFromUrl : undefined, + startTime: new Date(), + }); + } else { + // Update existing task's messages updateMessages(taskIdRef.current, updatedMessages); // Set existing task to running state updateTask(taskIdRef.current, { status: 'running' }); @@ -623,19 +652,24 @@ export default function main() { } try { - if (!taskIdRef.current) { - // Use IPC to call main thread's EkoService + // Check if current task is temporary + const isTemporaryTask = taskIdRef.current.startsWith('temp-'); + + if (isTemporaryTask) { + // Use IPC to call main thread's EkoService to run new task const req = window.api.ekoRun(message.trim()); setEkoRequest(req); result = await req; - result && setCurrentTaskId(result.taskId); + // Note: real taskId will be set via stream callback's replaceTaskId } else { + // Modify existing task const req = window.api.ekoModify(taskIdRef.current, message.trim()); setEkoRequest(req); result = await req; } + // Update task status based on result (directly using eko-core status) - if (result) { + if (result && taskIdRef.current) { updateTask(taskIdRef.current, { status: result.stopReason }); diff --git a/src/type.d.ts b/src/type.d.ts index b6f6e7c..a6f25f8 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -1,3 +1,32 @@ +// Supported providers +export type ProviderType = 'deepseek' | 'qwen' | 'google' | 'anthropic' | 'openrouter'; + +// Model configuration types +export interface UserModelConfigs { + deepseek?: { + apiKey?: string + baseURL?: string + model?: string + } + qwen?: { + apiKey?: string + model?: string + } + google?: { + apiKey?: string + model?: string + } + anthropic?: { + apiKey?: string + model?: string + } + openrouter?: { + apiKey?: string + model?: string + } + selectedProvider?: ProviderType +} + declare global { interface Window { api: { @@ -24,6 +53,14 @@ declare global { onEkoStreamMessage: (callback: (message: any) => void) => void ekoGetTaskStatus: (taskId: string) => Promise ekoCancelTask: (taskId: string) => Promise + + // Model configuration APIs + getUserModelConfigs: () => Promise + saveUserModelConfigs: (configs: UserModelConfigs) => Promise<{ success: boolean }> + getModelConfig: (provider: ProviderType) => Promise + getApiKeySource: (provider: ProviderType) => Promise<'user' | 'env' | 'none'> + getSelectedProvider: () => Promise + setSelectedProvider: (provider: ProviderType) => Promise<{ success: boolean }> } // PDF.js type declarations pdfjsLib?: { diff --git a/src/utils/messageTransform.ts b/src/utils/messageTransform.ts index bf09d41..b5a1676 100644 --- a/src/utils/messageTransform.ts +++ b/src/utils/messageTransform.ts @@ -17,7 +17,7 @@ export class MessageProcessor { // Process streaming messages and convert to structured display messages public processStreamMessage(message: StreamCallbackMessage): DisplayMessage[] { console.log('MessageProcessor processing message:', message.type, message); - + switch (message.type) { case 'workflow': this.handleWorkflowMessage(message); @@ -41,6 +41,9 @@ export class MessageProcessor { case 'agent_result': this.handleAgentResultMessage(message); break; + case 'error': + this.handleErrorMessage(message); + break; } console.log('MessageProcessor current message count:', this.messages.length); @@ -249,6 +252,30 @@ export class MessageProcessor { return [...this.messages]; } + // Handle error message + private handleErrorMessage(message: any) { + console.error('Error message received:', message); + + // Create error message as AgentGroupMessage with error status + const errorMsg: AgentGroupMessage = { + id: uuidv4(), + type: 'agent_group', + taskId: message.taskId || 'unknown', + agentName: 'System', + messages: [ + { + type: 'text', + id: uuidv4(), + content: `❌ Error: ${message.error || 'Unknown error occurred'}\n\n${message.detail || ''}` + } + ], + status: 'error', + timestamp: new Date() + }; + + this.messages.push(errorMsg); + } + // Get current message list public getMessages(): DisplayMessage[] { return [...this.messages]; From bc79d2af66b75bd7916b03d6c4e8e3b414862533 Mon Sep 17 00:00:00 2001 From: lsust Date: Thu, 23 Oct 2025 19:51:02 +0800 Subject: [PATCH 02/23] feat: improve UI layout and visibility control - Restrict scheduled task button to home page only - Add padding to query input area in home page - Update electron-builder.yml formatting --- electron-builder.yml | 3 ++- src/components/Header.tsx | 4 ++-- src/pages/home.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 635c7b3..cd5ec16 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -28,13 +28,14 @@ mac: target: - target: dmg arch: universal - identity: null + identity: null category: "public.app-category.developer-tools" type: "distribution" hardenedRuntime: true entitlements: "assets/entitlements.mac.plist" entitlementsInherit: "assets/entitlements.mac.plist" gatekeeperAssess: false + # notarize: true win: icon: assets/icons/logo.png diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c6ed10d..3f55d8a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -60,8 +60,8 @@ export default function Header() {
)}
- {/* Create task button - only show in main window */} - {!isTaskDetailMode && ( + {/* Create task button - only show in home page */} + {!isTaskDetailMode && (router.pathname === '/home' || router.pathname === '/') && ( , + , + , + ]} + style={{ minHeight: '60vh' }} + styles={{ + body: { minHeight: '50vh', maxHeight: '75vh', overflowY: 'auto' } + }} + > + {loading ? ( +
+ +
+ ) : ( + + {/* Browser Agent Tab */} + + +
+
+ Enable Browser Agent + + setConfig(prev => ({ + ...prev, + browserAgent: { ...prev.browserAgent, enabled } + })) + } + /> +
+ + Browser Agent handles web automation tasks like navigation, clicking, form filling, and content extraction. + +
+ + + +
+
+ Custom System Prompt + + Add custom instructions to extend the Browser Agent's capabilities. + +
+ +
+
+ Default behaviors: +
+
+ • Analyze webpages by taking screenshots and page element structures
+ • Use structured commands to interact with the browser
+ • Handle popups/cookies by accepting or closing them
+ • Request user help for login, verification codes, payments, etc.
+ • Use scroll to find elements, extract content with extract_page_content +
+
+ +