diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a88ad9dc3..3186966f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,7 @@ ### Related Issue + Closes # diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0b8f4e17..8439666e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,30 +18,30 @@ Eigent is a multi-agent system designed to deliver a high-quality open source Co **Our goals are:** 1. Pursue quality over quantity — in both code and features design within the Eigent repository. -2. Welcome any developer or user who truly uses Eigent, or shares our mission and vision, to discuss product and technology with us and bring the multi-agent open source Cowork system to more real users. +1. Welcome any developer or user who truly uses Eigent, or shares our mission and vision, to discuss product and technology with us and bring the multi-agent open source Cowork system to more real users. ### Why This Policy Exists As AI coding capabilities grow, an increasing number of AI coding bots or vibe code are introducing significant noise and risk to open-source repositories: 1. **Code quality risks.** AI-generated code may contain subtle bugs or hallucinations. An excessive volume of LLM-generated code is presumed to be polluted code and dramatically increases heavy and meaningless maintenance costs. -2. **Community culture.** For Eigent's community, we uphold the core value of human collaboration and oppose low-effort, low-signal spamming. +1. **Community culture.** For Eigent's community, we uphold the core value of human collaboration and oppose low-effort, low-signal spamming. ### Contribution Requirements We are taking the following precautionary steps to maintain the integrity of this open-source repository: 1. **PRs must reference a prior discussion.** Every PR must link to a previously discussed and accepted issue, Discord thread, or equivalent. Drive-by PRs with no associated accepted issue will be closed. -2. **No unreviewed LLM-generated submissions.** We will close PRs directly that are primarily generated by LLMs or chatbots and submitted without meaningful human review especially "vibe-coded" submissions. -3. **Human-verified testing is required.** Do not submit code that is "theoretically correct but untested." Every PR must include proof of testing (e.g., screenshots, screen recordings, test output logs). Very important! -4. **AI-assisted drafts are acceptable for issues, discussions, and prototypes**, but they must be reviewed and edited by a human to reduce verbosity and noise. +1. **No unreviewed LLM-generated submissions.** We will close PRs directly that are primarily generated by LLMs or chatbots and submitted without meaningful human review especially "vibe-coded" submissions. +1. **Human-verified testing is required.** Do not submit code that is "theoretically correct but untested." Every PR must include proof of testing (e.g., screenshots, screen recordings, test output logs). Very important! +1. **AI-assisted drafts are acceptable for issues, discussions, and prototypes**, but they must be reviewed and edited by a human to reduce verbosity and noise. ### Enforcement: Grounds for Immediate Ban The following abusive behaviors will result in an immediate ban (PR submission privileges revoked): 1. **Inauthentic contribution activity.** Using AI tools to artificially inflate open-source contribution metrics for personal or commercial gain. -2. **Bulk, low-quality, irrelevant, or misleading AI-generated content.** +1. **Bulk, low-quality, irrelevant, or misleading AI-generated content.** --- @@ -277,7 +277,7 @@ To run the application locally in developer mode: 1. Configure `.env.development`: - Set `VITE_USE_LOCAL_PROXY=true` - Set `VITE_PROXY_URL=http://localhost:3001` -2. Go to the settings to specify your model key and model type. +1. Go to the settings to specify your model key and model type. ## Common Actions 🔄 diff --git a/README.md b/README.md index f6dc9eab6..65b33bfef 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Built on [CAMEL-AI][camel-site]'s acclaimed open-source project, our system intr - [📄 Open Source License](#-open-source-license) - [🌐 Community & contact](#-community--contact) -#### +####
diff --git a/README_CN.md b/README_CN.md index 664266bfb..a8eb26594 100644 --- a/README_CN.md +++ b/README_CN.md @@ -74,7 +74,7 @@ - [📄 开源许可证](#-%E5%BC%80%E6%BA%90%E8%AE%B8%E5%8F%AF%E8%AF%81) - [🌐 社区与联系](#-%E7%A4%BE%E5%8C%BA%E4%B8%8E%E8%81%94%E7%B3%BB) -#### +####
diff --git a/README_JA.md b/README_JA.md index 7642cb52d..740cc4df7 100644 --- a/README_JA.md +++ b/README_JA.md @@ -73,7 +73,7 @@ - [📄 オープンソースライセンス](#-%E3%82%AA%E3%83%BC%E3%83%97%E3%83%B3%E3%82%BD%E3%83%BC%E3%82%B9%E3%83%A9%E3%82%A4%E3%82%BB%E3%83%B3%E3%82%B9) - [🌐 コミュニティ & お問い合わせ](#-%E3%82%B3%E3%83%9F%E3%83%A5%E3%83%8B%E3%83%86%E3%82%A3--%E3%81%8A%E5%95%8F%E3%81%84%E5%90%88%E3%82%8F%E3%81%9B) -#### +####
diff --git a/README_PT-BR.md b/README_PT-BR.md index 0b8654b7f..326d527e8 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -74,7 +74,7 @@ Construído sobre o aclamado projeto open source da [CAMEL-AI][camel-site], noss - [📄 Licença Open Source](#-licen%C3%A7a-open-source) - [🌐 Comunidade & Contato](#-comunidade--contato) -#### +####
diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 385ae167f..4ad29b804 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -27,6 +27,7 @@ # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.search_toolkit import SearchToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR from app.component.environment import env @@ -97,6 +98,13 @@ def browser_agent(options: Chat): ) note_toolkit = message_integration.register_toolkits(note_toolkit) + skill_toolkit = SkillToolkit( + options.project_id, + Agents.browser_agent, + working_directory=working_directory, + ) + skill_toolkit = message_integration.register_toolkits(skill_toolkit) + search_tools = SearchToolkit.get_can_use_tools(options.project_id) if search_tools: search_tools = message_integration.register_functions(search_tools) @@ -111,6 +119,7 @@ def browser_agent(options: Chat): *terminal_toolkit, *note_toolkit.get_tools(), *search_tools, + *skill_toolkit.get_tools(), ] system_message = BROWSER_SYS_PROMPT.format( @@ -135,6 +144,7 @@ def browser_agent(options: Chat): HumanToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), TerminalToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), ], toolkits_to_register_agent=[web_toolkit_for_agent_registration], enable_snapshot_clean=True, diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index 96359e055..9e4292778 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -25,6 +25,7 @@ # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.screenshot_toolkit import ScreenshotToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.web_deploy_toolkit import WebDeployToolkit from app.agent.utils import NOW_STR @@ -70,6 +71,13 @@ async def developer_agent(options: Chat): ) terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) + skill_toolkit = SkillToolkit( + options.project_id, + Agents.developer_agent, + working_directory=working_directory, + ) + skill_toolkit = message_integration.register_toolkits(skill_toolkit) + tools = [ *HumanToolkit.get_can_use_tools( options.project_id, Agents.developer_agent @@ -78,6 +86,7 @@ async def developer_agent(options: Chat): *web_deploy_toolkit.get_tools(), *terminal_toolkit.get_tools(), *screenshot_toolkit.get_tools(), + *skill_toolkit.get_tools(), ] system_message = DEVELOPER_SYS_PROMPT.format( platform_system=platform.system(), @@ -99,5 +108,6 @@ async def developer_agent(options: Chat): TerminalToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), WebDeployToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), ], ) diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index 0d3b2c897..b272c7365 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -28,6 +28,7 @@ # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.pptx_toolkit import PPTXToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR from app.model.chat import Chat @@ -82,6 +83,13 @@ async def document_agent(options: Chat): options.project_id, options.get_bun_env() ) + skill_toolkit = SkillToolkit( + options.project_id, + Agents.document_agent, + working_directory=working_directory, + ) + skill_toolkit = message_integration.register_toolkits(skill_toolkit) + tools = [ *file_write_toolkit.get_tools(), *pptx_toolkit.get_tools(), @@ -93,6 +101,7 @@ async def document_agent(options: Chat): *note_toolkit.get_tools(), *terminal_toolkit.get_tools(), *google_drive_tools, + *skill_toolkit.get_tools(), ] system_message = DOCUMENT_SYS_PROMPT.format( platform_system=platform.system(), @@ -118,5 +127,6 @@ async def document_agent(options: Chat): NoteTakingToolkit.toolkit_name(), TerminalToolkit.toolkit_name(), GoogleDriveMCPToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), ], ) diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index fea7b1c29..6ca3e16a0 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -29,6 +29,7 @@ from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.openai_image_toolkit import OpenAIImageToolkit from app.agent.toolkit.search_toolkit import SearchToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.video_download_toolkit import VideoDownloaderToolkit from app.agent.utils import NOW_STR @@ -75,6 +76,13 @@ def multi_modal_agent(options: Chat): working_directory=working_directory, ) note_toolkit = message_integration.register_toolkits(note_toolkit) + + skill_toolkit = SkillToolkit( + options.project_id, + Agents.multi_modal_agent, + working_directory=working_directory, + ) + skill_toolkit = message_integration.register_toolkits(skill_toolkit) tools = [ *video_download_toolkit.get_tools(), *image_analysis_toolkit.get_tools(), @@ -83,6 +91,7 @@ def multi_modal_agent(options: Chat): ), *terminal_toolkit.get_tools(), *note_toolkit.get_tools(), + *skill_toolkit.get_tools(), ] if options.is_cloud(): # TODO: check llm has this model @@ -147,5 +156,6 @@ def multi_modal_agent(options: Chat): TerminalToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), SearchToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), ], ) diff --git a/backend/app/agent/factory/social_media.py b/backend/app/agent/factory/social_media.py index 649613bb6..77b8b86c4 100644 --- a/backend/app/agent/factory/social_media.py +++ b/backend/app/agent/factory/social_media.py @@ -25,6 +25,7 @@ from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.notion_mcp_toolkit import NotionMCPToolkit from app.agent.toolkit.reddit_toolkit import RedditToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.twitter_toolkit import TwitterToolkit from app.agent.toolkit.whatsapp_toolkit import WhatsAppToolkit @@ -70,6 +71,11 @@ async def social_media_agent(options: Chat): Agents.social_media_agent, working_directory=working_directory, ).get_tools(), + *SkillToolkit( + options.project_id, + Agents.social_media_agent, + working_directory=working_directory, + ).get_tools(), # *DiscordToolkit(options.project_id).get_tools(), # *GoogleSuiteToolkit(options.project_id).get_tools(), ] @@ -94,5 +100,6 @@ async def social_media_agent(options: Chat): HumanToolkit.toolkit_name(), TerminalToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), ], ) diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index ee1d782d0..99b8f5b76 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -29,46 +29,54 @@ Your integrated toolkits enable you to: -1. WhatsApp Business Management (WhatsAppToolkit): +1. Skills System: You have access to a library of specialized skills that + provide expert guidance for specific tasks. When a skill is referenced with + double curly braces (e.g., {{pdf}} or {{data-analyzer}}), you should use + `list_skills` to discover available skills and `load_skill` to retrieve the + skill's full content. Skills contain tested code examples, best practices, + and detailed instructions that you MUST follow as your PRIMARY reference when + working on tasks that mention them. + +2. WhatsApp Business Management (WhatsAppToolkit): - Send text and template messages to customers via the WhatsApp Business API. - Retrieve business profile information. -2. Twitter Account Management (TwitterToolkit): +3. Twitter Account Management (TwitterToolkit): - Create tweets with text content, polls, or as quote tweets. - Delete existing tweets. - Retrieve user profile information. -3. LinkedIn Professional Networking (LinkedInToolkit): +4. LinkedIn Professional Networking (LinkedInToolkit): - Create posts on LinkedIn. - Delete existing posts. - Retrieve authenticated user's profile information. -4. Reddit Content Analysis (RedditToolkit): +5. Reddit Content Analysis (RedditToolkit): - Collect top posts and comments from specified subreddits. - Perform sentiment analysis on Reddit comments. - Track keyword discussions across multiple subreddits. -5. Notion Workspace Management (NotionToolkit): +6. Notion Workspace Management (NotionToolkit): - List all pages and users in a Notion workspace. - Retrieve and extract text content from Notion blocks. -6. Slack Workspace Interaction (SlackToolkit): +7. Slack Workspace Interaction (SlackToolkit): - Create new Slack channels (public or private). - Join or leave existing channels. - Send and delete messages in channels. - Retrieve channel information and message history. -7. Human Interaction (HumanToolkit): +8. Human Interaction (HumanToolkit): - Ask questions to users and send messages via console. -8. Agent Communication: +9. Agent Communication: - Communicate with other agents using messaging tools when collaboration is needed. Use `list_available_agents` to see available team members and `send_message` to coordinate with them, especially when you need content from document agents or research from browser agents. -9. File System Access: +10. File System Access: - You can use terminal tools to interact with the local file system in your working directory (`{working_directory}`), for example, to access files needed for posting. **IMPORTANT:** Before the task gets started, you can @@ -78,7 +86,7 @@ `grep` to search within them, and `curl` to interact with web APIs that are not covered by other tools. -10. Note-Taking & Cross-Agent Collaboration (NoteTakingToolkit): +11. Note-Taking & Cross-Agent Collaboration (NoteTakingToolkit): - Discover existing notes from other agents with `list_note()`. - Read note content with `read_note()`. - Record your findings and share information with `create_note()` and `append_note()`. @@ -142,6 +150,13 @@ Your capabilities include: +- **Skills System**: You have access to a library of specialized skills that + provide expert guidance for specific tasks. When a skill is referenced with + double curly braces (e.g., {{pdf}} or {{data-analyzer}}), you should use + `list_skills` to discover available skills and `load_skill` to retrieve the + skill's full content. Skills contain tested code examples, best practices, + and detailed instructions that you MUST follow as your PRIMARY reference when + working on tasks that mention them. - Video & Audio Analysis: - Download videos from URLs for analysis. - Transcribe speech from audio files to text with high accuracy @@ -263,6 +278,13 @@ Your capabilities include: +- **Skills System**: You have access to a library of specialized skills that + provide expert guidance for specific tasks. When a skill is referenced with + double curly braces (e.g., {{pdf}} or {{data-analyzer}}), you should use + `list_skills` to discover available skills and `load_skill` to retrieve the + skill's full content. Skills contain tested code examples, best practices, + and detailed instructions that you MUST follow as your PRIMARY reference when + working on tasks that mention them. - Document Reading: - Read and understand the content of various file formats including - PDF (.pdf) @@ -413,6 +435,13 @@ Your capabilities are extensive and powerful: +- **Skills System**: You have access to a library of specialized skills that + provide expert guidance for specific tasks. When a skill is referenced with + double curly braces (e.g., {{pdf}} or {{data-analyzer}}), you should use + `list_skills` to discover available skills and `load_skill` to retrieve the + skill's full content. Skills contain tested code examples, best practices, + and detailed instructions that you MUST follow as your PRIMARY reference when + working on tasks that mention them. - **Unrestricted Code Execution**: You can write and execute code in any language to solve a task. You MUST first save your code to a file (e.g., `script.py`) and then run it from the terminal (e.g., @@ -581,6 +610,13 @@ Your capabilities include: +- **Skills System**: You have access to a library of specialized skills that + provide expert guidance for specific tasks. When a skill is referenced with + double curly braces (e.g., {{pdf}} or {{data-analyzer}}), you should use + `list_skills` to discover available skills and `load_skill` to retrieve the + skill's full content. Skills contain tested code examples, best practices, + and detailed instructions that you MUST follow as your PRIMARY reference when + working on tasks that mention them. - Search and get information from the web using the search tools. - Use the rich browser related toolset to investigate websites. - Use the terminal tools to perform local operations. **IMPORTANT:** Before the diff --git a/backend/app/agent/toolkit/skill_toolkit.py b/backend/app/agent/toolkit/skill_toolkit.py new file mode 100644 index 000000000..62ad78e1c --- /dev/null +++ b/backend/app/agent/toolkit/skill_toolkit.py @@ -0,0 +1,336 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +Skill Toolkit with multi-tier hierarchy: + +Agent access control is managed via skills-config.json. +User isolation is managed via ~/.eigent//skills-config.json. +""" + +import json +import logging +from pathlib import Path +from typing import Literal + +from camel.toolkits.function_tool import FunctionTool +from camel.toolkits.skill_toolkit import SkillToolkit as BaseSkillToolkit + +logger = logging.getLogger(__name__) + +SKILL_FILENAME = "SKILL.md" +SKILL_CONFIG_FILENAME = "skills-config.json" + +# Unified scope naming +SkillScope = Literal["repo", "user", "system"] + + +def _get_user_config_path(user_id: str | None = None) -> Path: + """Get the config path for a specific user. + + Args: + user_id: User identifier. If None, uses legacy global path. + + Returns: + Path to user's config file + """ + if user_id: + # User-specific config: ~/.eigent//skills-config.json + return Path.home() / ".eigent" / str(user_id) / SKILL_CONFIG_FILENAME + else: + # Legacy global config: ~/.eigent/skills-config.json + return Path.home() / ".eigent" / SKILL_CONFIG_FILENAME + + +def _load_skill_config(config_path: Path) -> dict[str, dict]: + """Load skill configuration from JSON file.""" + if not config_path.exists(): + logger.debug(f"No config file at: {config_path}") + return {} + + try: + with open(config_path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "skills" in data: + return data.get("skills", {}) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"Failed to load skill config from {config_path}: {e}") + return {} + + +def _get_merged_skill_config( + working_directory: Path | None = None, + user_id: str | None = None, +) -> dict[str, dict]: + """Get merged skill configuration (user-global + project-level). + + Priority: Project-level > User-global + + Args: + working_directory: Current working directory + user_id: User identifier for loading user-specific config + + Returns: + Merged skill configuration + """ + wd = working_directory if working_directory is not None else Path.cwd() + wd = wd if isinstance(wd, Path) else Path(wd) + + # Load user-specific global config + user_config_path = _get_user_config_path(user_id) + config = _load_skill_config(user_config_path) + logger.debug( + f"Loaded user config (user_id={user_id or 'legacy'}): " + f"{len(config)} skills from {user_config_path}" + ) + + # Load project-level config (overrides user config) + project_config_path = wd / ".eigent" / SKILL_CONFIG_FILENAME + project_config = _load_skill_config(project_config_path) + if project_config: + logger.debug( + f"Loaded project skill config: {len(project_config)} skills" + ) + config.update(project_config) + + return config + + +def _is_skill_enabled(skill_name: str, config: dict[str, dict]) -> bool: + """Check if a skill is enabled according to config.""" + if not config or skill_name not in config: + return True # Not configured = enabled by default + + skill_config = config[skill_name] + return skill_config.get("enabled", True) + + +def _is_agent_allowed( + skill_name: str, + agent_name: str | None, + config: dict[str, dict], +) -> bool: + """Check if an agent is allowed to use this skill. + + Args: + skill_name: Name of the skill + agent_name: Name of the agent requesting the skill + config: Skill configuration + + Returns: + True if agent is allowed, False otherwise + """ + if not config or skill_name not in config: + return True # Not configured = all agents allowed + + skill_config = config[skill_name] + + scope = skill_config.get("scope") + if isinstance(scope, dict): + is_global = scope.get("isGlobal", True) + selected_agents = scope.get("selectedAgents", []) + + # If isGlobal is True, all agents are allowed + if is_global: + return True + + if not selected_agents: + return False + + if not agent_name: + logger.warning( + f"No agent_name provided for skill '{skill_name}' " + f"with agent restrictions: {selected_agents}" + ) + return False + + return agent_name in selected_agents + + allowed_agents = skill_config.get("agents", []) + + # Empty list = all agents allowed + if not allowed_agents: + return True + + if not agent_name: + logger.warning( + f"No agent_name provided for skill '{skill_name}' " + f"with agent restrictions: {allowed_agents}" + ) + return False + + return agent_name in allowed_agents + + +class SkillToolkit(BaseSkillToolkit): + """Enhanced SkillToolkit with Eigent-specific features. + + Extends CAMEL's SkillToolkit with: + - User-specific skill configuration + - Agent-based access control + - Eigent-specific skill paths (.eigent/skills) + + Skill Discovery Priority (highest to lowest): + 1. Repo scope: /skills, /.eigent/skills, /.camel/skills + 2. User scope: ~/.eigent/skills, ~/.camel/skills, ~/.config/camel/skills + 3. System scope: /etc/camel/skills + + Agent access control is managed via skills-config.json (agents field). + User isolation is managed via ~/.eigent//skills-config.json. + """ + + @classmethod + def toolkit_name(cls) -> str: + return "SkillToolkit" + + def __init__( + self, + api_task_id: str, + agent_name: str | None = None, + working_directory: str | None = None, + user_id: str | None = None, + timeout: float | None = None, + ) -> None: + """Initialize SkillToolkit with Eigent-specific context. + + Args: + api_task_id: Task/project identifier for logging + agent_name: Name of the agent (e.g., "developer", "browser") + working_directory: Base directory for skill discovery + user_id: User identifier for loading user-specific config + timeout: Optional timeout for skill execution + """ + self.api_task_id = api_task_id + self.agent_name = agent_name + self.user_id = user_id + logger.info( + f"Initialized SkillToolkit for agent '{agent_name}' " + f"in task '{api_task_id}' (user_id={user_id or 'legacy'})" + ) + super().__init__( + working_directory=working_directory, + timeout=timeout, + ) + + def _skill_roots(self) -> list[tuple[str, Path]]: + """Return skill roots with Eigent + CAMEL paths. + + Integrates Eigent-specific paths with CAMEL standard paths. + Priority order (highest to lowest): + 1. Repo scope: project-specific skills + 2. User scope: user-level skills + 3. System scope: system-wide skills + + Returns: + List of (scope, path) tuples in priority order + """ + roots: list[tuple[str, Path]] = [] + + # 1. Repo scope - project-specific skills (highest priority) + roots.append(("repo", self.working_directory / "skills")) + roots.append(("repo", self.working_directory / ".eigent" / "skills")) + roots.append(("repo", self.working_directory / ".camel" / "skills")) + roots.append(("repo", self.working_directory / ".agents" / "skills")) + + # 2. User scope - user-level skills + roots.append(("user", Path.home() / ".eigent" / "skills")) + roots.append(("user", Path.home() / ".camel" / "skills")) + roots.append(("user", Path.home() / ".config" / "camel" / "skills")) + + # 3. System scope - system-wide skills (lowest priority) + roots.append(("system", Path("/etc/camel/skills"))) + + logger.debug( + f"Skill roots configured for {self.agent_name}: {len(roots)} paths" + ) + + return roots + + def _apply_access_control( + self, skills: dict[str, dict[str, str]] + ) -> dict[str, dict[str, str]]: + """Apply agent-based access control to discovered skills. + + Args: + skills: Dict of discovered skills from base class + + Returns: + Filtered dict of skills based on configuration + """ + # Load merged config (user + project) + config = _get_merged_skill_config(self.working_directory, self.user_id) + + if not config: + # No config = all skills available + return skills + + filtered = {} + for name, metadata in skills.items(): + skill_name = metadata["name"] + + # Check if skill is enabled + if not _is_skill_enabled(skill_name, config): + logger.debug( + f"Skill '{skill_name}' disabled for user " + f"'{self.user_id or 'legacy'}'" + ) + continue + + # Check if agent is allowed + if not _is_agent_allowed(skill_name, self.agent_name, config): + logger.debug( + f"Skill '{skill_name}' not allowed for agent " + f"'{self.agent_name}'" + ) + continue + + filtered[name] = metadata + + logger.debug( + f"Access control: {len(skills)} -> {len(filtered)} skills " + f"(agent={self.agent_name}, user={self.user_id or 'legacy'})" + ) + + return filtered + + def _get_skills(self) -> dict[str, dict[str, str]]: + """Override to apply access control to discovered skills. + + Returns: + Dict of skills after applying access control + """ + # Get skills from base class (with caching) + skills = super()._get_skills() + + # Apply Eigent-specific access control + return self._apply_access_control(skills) + + def get_tools(self) -> list[FunctionTool]: + """Return skill tools with access control applied. + + The returned tools will respect: + - User-specific configurations (~/.eigent//skills-config.json) + - Project-level configurations (.eigent/skills-config.json) + - Agent-based access restrictions + + Returns: + List of FunctionTool instances for skill operations + """ + tools = super().get_tools() + logger.debug( + f"Created {len(tools)} skill tools for agent '{self.agent_name}' " + f"(user_id={self.user_id or 'legacy'})" + ) + return tools diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 58d0e8bfe..363194bb3 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -76,6 +76,8 @@ class Chat(BaseModel): # User-specific search engine configurations # (e.g., GOOGLE_API_KEY, SEARCH_ENGINE_ID) search_config: dict[str, str] | None = None + # User identifier for user-specific skill configurations + user_id: str | None = None @field_validator("model_platform") @classmethod diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index dce9c8e09..aa10de74d 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -41,6 +41,7 @@ from app.agent.listen_chat_agent import ListenChatAgent from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json @@ -308,8 +309,14 @@ def build_conversation_context( return context -def build_context_for_workforce(task_lock: TaskLock, options: Chat) -> str: - """Build context information for workforce.""" +def build_context_for_workforce( + task_lock: TaskLock, + options: Chat, + task_content: str | None = None, +) -> str: + """Build context information for workforce. + Instructs coordinator to actively load skills using list_skills/load_skill tools. + """ return build_conversation_context( task_lock, header="=== CONVERSATION HISTORY ===" ) @@ -2183,7 +2190,13 @@ def _create_coordinator_and_task_agents() -> list[ListenChatAgent]: working_directory=working_directory, ) ) - ).get_tools() + ).get_tools(), + *SkillToolkit( + options.project_id, + key, + working_directory=working_directory, + user_id=options.user_id, + ).get_tools(), ], ) for key, prompt in { @@ -2250,6 +2263,12 @@ def _create_new_worker_agent() -> ListenChatAgent: ) ) ).get_tools(), + *SkillToolkit( + options.project_id, + Agents.new_worker_agent, + working_directory=working_directory, + user_id=options.user_id, + ).get_tools(), ], ) diff --git a/backend/app/utils/file_utils.py b/backend/app/utils/file_utils.py index b5cc78796..3a0cc72b1 100644 --- a/backend/app/utils/file_utils.py +++ b/backend/app/utils/file_utils.py @@ -13,9 +13,15 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= """File system utilities.""" +import logging +import shutil +from pathlib import Path + from app.component.environment import env from app.model.chat import Chat +logger = logging.getLogger(__name__) + def get_working_directory(options: Chat, task_lock=None) -> str: """ @@ -36,3 +42,36 @@ def get_working_directory(options: Chat, task_lock=None) -> str: return str(task_lock.new_folder_path) else: return env("file_save_path", options.file_save_path()) + + +def sync_eigent_skills_to_project(working_directory: str) -> None: + """ + Copy skills from ~/.eigent/skills into the project's .eigent/skills + so the agent can load and execute them from the project working directory. + """ + src = Path.home() / ".eigent" / "skills" + dst = Path(working_directory) / ".eigent" / "skills" + if not src.is_dir(): + return + try: + dst.mkdir(parents=True, exist_ok=True) + for skill_dir in src.iterdir(): + if skill_dir.is_dir(): + dest_skill = dst / skill_dir.name + if dest_skill.exists(): + shutil.rmtree(dest_skill) + shutil.copytree(skill_dir, dest_skill) + logger.debug( + "Synced eigent skills to project", + extra={ + "working_directory": working_directory, + "destination": str(dst), + }, + ) + except OSError as e: + logger.warning( + "Failed to sync ~/.eigent/skills to project %s: %s", + working_directory, + e, + exc_info=True, + ) diff --git a/backend/scripts/init_skills_config.py b/backend/scripts/init_skills_config.py new file mode 100644 index 000000000..4437aeff0 --- /dev/null +++ b/backend/scripts/init_skills_config.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +Initialize skills configuration file with default settings. + +This script creates the skills-config.json file if it doesn't exist, +and optionally scans for existing skills to add them to the config. +""" + +import json +import sys +from pathlib import Path + + +def init_global_config( + user_id: str | None = None, scan_skills: bool = True +) -> None: + """Initialize global skills configuration. + + Args: + user_id: User identifier for user-specific config. If None, uses legacy path. + scan_skills: If True, scan ~/.eigent/skills/ and add found skills to config + """ + if user_id: + # User-specific config: ~/.eigent//skills-config.json + config_path = ( + Path.home() / ".eigent" / str(user_id) / "skills-config.json" + ) + else: + # Legacy global config: ~/.eigent/skills-config.json + config_path = Path.home() / ".eigent" / "skills-config.json" + + skills_dir = Path.home() / ".eigent" / "skills" + + # Check if config already exists + if config_path.exists(): + print(f"✅ Config already exists: {config_path}") + with open(config_path) as f: + config = json.load(f) + print(f" Current skills: {list(config.get('skills', {}).keys())}") + return + + # Ensure directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Initialize config structure + config = {"version": 1, "skills": {}} + + # Scan for existing skills if requested + if scan_skills and skills_dir.exists(): + print(f"📂 Scanning for skills in {skills_dir}...") + for skill_dir in skills_dir.iterdir(): + if skill_dir.is_dir() and not skill_dir.name.startswith("."): + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + skill_name = skill_dir.name + # Parse skill name from frontmatter + try: + content = skill_md.read_text(encoding="utf-8") + import re + + match = re.search( + r"^---\s*\nname:\s*(.+?)\s*\n", + content, + re.MULTILINE, + ) + if match: + skill_name = match.group(1).strip() + except Exception: + pass + + # Add to config (enabled by default) + config["skills"][skill_name] = { + "enabled": True, + "scope": "global", + "addedAt": int(__import__("time").time() * 1000), + "isExample": False, + } + print(f" ✅ Found: {skill_name}") + + # Save config + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + print(f"\n✨ Created config: {config_path}") + print(f" Total skills: {len(config['skills'])}") + + +def init_project_config(project_path: str) -> None: + """Initialize project-level skills configuration. + + Args: + project_path: Path to the project directory + """ + project_dir = Path(project_path) + if not project_dir.exists(): + print(f"❌ Project directory does not exist: {project_path}") + sys.exit(1) + + config_path = project_dir / ".eigent" / "skills-config.json" + + # Check if config already exists + if config_path.exists(): + print(f"✅ Project config already exists: {config_path}") + with open(config_path) as f: + config = json.load(f) + print(f" Current skills: {list(config.get('skills', {}).keys())}") + return + + # Ensure directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Initialize empty project config + config = {"version": 1, "skills": {}} + + # Save config + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + print(f"\n✨ Created project config: {config_path}") + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Initialize skills configuration files" + ) + parser.add_argument( + "--scope", + choices=["global", "project"], + default="global", + help="Configuration scope (default: global)", + ) + parser.add_argument( + "--project-path", + type=str, + help="Project path (required for project scope)", + ) + parser.add_argument( + "--no-scan", + action="store_true", + help="Don't scan for existing skills (global only)", + ) + parser.add_argument( + "--user-id", + type=str, + help="User ID for user-specific config (optional)", + ) + + args = parser.parse_args() + + if args.scope == "global": + if args.user_id: + print( + f"🔧 Initializing user-specific skills configuration for user '{args.user_id}'...\n" + ) + else: + print("🔧 Initializing global skills configuration...\n") + init_global_config(user_id=args.user_id, scan_skills=not args.no_scan) + elif args.scope == "project": + if not args.project_path: + print("❌ --project-path is required for project scope") + sys.exit(1) + print( + f"🔧 Initializing project skills configuration for {args.project_path}...\n" + ) + init_project_config(args.project_path) + + print("\n✅ Done!") + + +if __name__ == "__main__": + main() diff --git a/docs/core/models/gemini.md b/docs/core/models/gemini.md index c07df96e7..ef241789c 100644 --- a/docs/core/models/gemini.md +++ b/docs/core/models/gemini.md @@ -48,7 +48,7 @@ Click on the Gemini Config card and fill in the following fields: ![Gemini 4 Pn](/docs/images/gemini_3.png) -______________________________________________________________________ +--- > **Video Tutorial:** Prefer a visual guide? Watch the full configuration video > here. diff --git a/docs/core/models/kimi.md b/docs/core/models/kimi.md index bb55b8863..dfe2c8b70 100644 --- a/docs/core/models/kimi.md +++ b/docs/core/models/kimi.md @@ -48,4 +48,4 @@ Click on the Moonshot card and fill in the following fields: ![Kimi 4 Pn](/docs/images/kimi_3.png) -______________________________________________________________________ +--- diff --git a/docs/core/models/minimax.md b/docs/core/models/minimax.md index 6e9425491..c60a044ae 100644 --- a/docs/core/models/minimax.md +++ b/docs/core/models/minimax.md @@ -47,4 +47,4 @@ Click on the Minimax Config card and fill in the following fields: ![Minimax 4 Pn](/docs/images/minimax_3.png) -______________________________________________________________________ +--- diff --git a/electron-builder.json b/electron-builder.json index c01c6b5ff..66b3af230 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -45,6 +45,10 @@ "!uv_python/**/site-packages/setuptools/**", "!uv_python/**/site-packages/wheel/**" ] + }, + { + "from": "resources/example-skills", + "to": "example-skills" } ], "protocols": [ diff --git a/electron/main/index.ts b/electron/main/index.ts index b2001bbf0..3e821e39a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -34,6 +34,7 @@ import os, { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import kill from 'tree-kill'; +import * as unzipper from 'unzipper'; import { copyBrowserData } from './copy'; import { FileReader } from './fileReader'; import { @@ -826,6 +827,370 @@ function registerIpcHandlers() { } }); + // ======================== skills ======================== + // SKILLS_ROOT, SKILL_FILE, seedDefaultSkillsIfEmpty are defined at module level (used at startup too). + function parseSkillFrontmatter( + content: string + ): { name: string; description: string } | null { + if (!content.startsWith('---')) return null; + const end = content.indexOf('\n---', 3); + const block = end > 0 ? content.slice(4, end) : content.slice(4); + const nameMatch = block.match(/^\s*name\s*:\s*(.+)$/m); + const descMatch = block.match(/^\s*description\s*:\s*(.+)$/m); + const name = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); + const desc = descMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); + if (name && desc) return { name, description: desc }; + return null; + } + + ipcMain.handle('get-skills-dir', async () => { + try { + if (!existsSync(SKILLS_ROOT)) { + await fsp.mkdir(SKILLS_ROOT, { recursive: true }); + } + await seedDefaultSkillsIfEmpty(); + return { success: true, path: SKILLS_ROOT }; + } catch (error: any) { + log.error('get-skills-dir failed', error); + return { success: false, error: error?.message }; + } + }); + + ipcMain.handle('skills-scan', async () => { + try { + if (!existsSync(SKILLS_ROOT)) { + return { success: true, skills: [] }; + } + await seedDefaultSkillsIfEmpty(); + const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); + const skills: Array<{ + name: string; + description: string; + path: string; + scope: string; + skillDirName: string; + }> = []; + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith('.')) continue; + const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); + try { + const raw = await fsp.readFile(skillPath, 'utf-8'); + const meta = parseSkillFrontmatter(raw); + if (meta) { + skills.push({ + name: meta.name, + description: meta.description, + path: skillPath, + scope: 'user', + skillDirName: e.name, + }); + } + } catch (_) { + // skip invalid or unreadable skill + } + } + return { success: true, skills }; + } catch (error: any) { + log.error('skills-scan failed', error); + return { success: false, error: error?.message, skills: [] }; + } + }); + + ipcMain.handle( + 'skill-write', + async (_event, skillDirName: string, content: string) => { + try { + const dir = path.join(SKILLS_ROOT, skillDirName); + await fsp.mkdir(dir, { recursive: true }); + await fsp.writeFile(path.join(dir, SKILL_FILE), content, 'utf-8'); + return { success: true }; + } catch (error: any) { + log.error('skill-write failed', error); + return { success: false, error: error?.message }; + } + } + ); + + ipcMain.handle('skill-delete', async (_event, skillDirName: string) => { + try { + const dir = path.join(SKILLS_ROOT, skillDirName); + if (!existsSync(dir)) return { success: true }; + await fsp.rm(dir, { recursive: true, force: true }); + return { success: true }; + } catch (error: any) { + log.error('skill-delete failed', error); + return { success: false, error: error?.message }; + } + }); + + ipcMain.handle('skill-read', async (_event, filePath: string) => { + try { + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(SKILLS_ROOT, filePath, SKILL_FILE); + const content = await fsp.readFile(fullPath, 'utf-8'); + return { success: true, content }; + } catch (error: any) { + log.error('skill-read failed', error); + return { success: false, error: error?.message }; + } + }); + + ipcMain.handle('skill-list-files', async (_event, skillDirName: string) => { + try { + const dir = path.join(SKILLS_ROOT, skillDirName); + if (!existsSync(dir)) + return { success: false, error: 'Skill folder not found', files: [] }; + const entries = await fsp.readdir(dir, { withFileTypes: true }); + const files = entries.map((e) => + e.isDirectory() ? `${e.name}/` : e.name + ); + return { success: true, files }; + } catch (error: any) { + log.error('skill-list-files failed', error); + return { success: false, error: error?.message, files: [] }; + } + }); + + ipcMain.handle('open-skill-folder', async (_event, skillName: string) => { + try { + const name = String(skillName || '').trim(); + if (!name) return { success: false, error: 'Skill name is required' }; + if (!existsSync(SKILLS_ROOT)) + return { success: false, error: 'Skills dir not found' }; + const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); + const nameLower = name.toLowerCase(); + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith('.')) continue; + const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); + try { + const raw = await fsp.readFile(skillPath, 'utf-8'); + const meta = parseSkillFrontmatter(raw); + if (meta && meta.name.toLowerCase().trim() === nameLower) { + const dirPath = path.join(SKILLS_ROOT, e.name); + await shell.openPath(dirPath); + return { success: true }; + } + } catch (_) { + continue; + } + } + return { success: false, error: `Skill not found: ${name}` }; + } catch (error: any) { + log.error('open-skill-folder failed', error); + return { success: false, error: error?.message }; + } + }); + + // ======================== skills-config.json handlers ======================== + + function getSkillConfigPath(userId: string): string { + return path.join(os.homedir(), '.eigent', userId, 'skills-config.json'); + } + + async function loadSkillConfig(userId: string): Promise { + const configPath = getSkillConfigPath(userId); + + // Auto-create config file if it doesn't exist + if (!existsSync(configPath)) { + const defaultConfig = { version: 1, skills: {} }; + try { + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile( + configPath, + JSON.stringify(defaultConfig, null, 2), + 'utf-8' + ); + log.info(`Auto-created skills config at ${configPath}`); + return defaultConfig; + } catch (error) { + log.error('Failed to create default skills config', error); + return defaultConfig; + } + } + + try { + const content = await fsp.readFile(configPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + log.error('Failed to load skill config', error); + return { version: 1, skills: {} }; + } + } + + async function saveSkillConfig(userId: string, config: any): Promise { + const configPath = getSkillConfigPath(userId); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } + + ipcMain.handle('skill-config-load', async (_event, userId: string) => { + try { + const config = await loadSkillConfig(userId); + return { success: true, config }; + } catch (error: any) { + log.error('skill-config-load failed', error); + return { success: false, error: error?.message }; + } + }); + + ipcMain.handle( + 'skill-config-toggle', + async (_event, userId: string, skillName: string, enabled: boolean) => { + try { + const config = await loadSkillConfig(userId); + if (!config.skills[skillName]) { + // Use SkillScope object format + config.skills[skillName] = { + enabled, + scope: { + isGlobal: true, + selectedAgents: [], + }, + addedAt: Date.now(), + isExample: false, + }; + } else { + config.skills[skillName].enabled = enabled; + } + await saveSkillConfig(userId, config); + return { success: true, config: config.skills[skillName] }; + } catch (error: any) { + log.error('skill-config-toggle failed', error); + return { success: false, error: error?.message }; + } + } + ); + + ipcMain.handle( + 'skill-config-update', + async (_event, userId: string, skillName: string, skillConfig: any) => { + try { + const config = await loadSkillConfig(userId); + config.skills[skillName] = { ...skillConfig }; + await saveSkillConfig(userId, config); + return { success: true }; + } catch (error: any) { + log.error('skill-config-update failed', error); + return { success: false, error: error?.message }; + } + } + ); + + ipcMain.handle( + 'skill-config-delete', + async (_event, userId: string, skillName: string) => { + try { + const config = await loadSkillConfig(userId); + delete config.skills[skillName]; + await saveSkillConfig(userId, config); + return { success: true }; + } catch (error: any) { + log.error('skill-config-delete failed', error); + return { success: false, error: error?.message }; + } + } + ); + + // Initialize skills config for a user (ensures config file exists) + ipcMain.handle('skill-config-init', async (_event, userId: string) => { + try { + log.info(`[SKILLS-CONFIG] Initializing config for user: ${userId}`); + const config = await loadSkillConfig(userId); + + try { + const exampleSkillsDir = getExampleSkillsSourceDir(); + const defaultConfigPath = path.join( + exampleSkillsDir, + 'default-config.json' + ); + + if (existsSync(defaultConfigPath)) { + const defaultConfigContent = await fsp.readFile( + defaultConfigPath, + 'utf-8' + ); + const defaultConfig = JSON.parse(defaultConfigContent); + + if (defaultConfig.skills) { + let addedCount = 0; + // Merge default skills config with user's existing config + for (const [skillName, skillConfig] of Object.entries( + defaultConfig.skills + )) { + if (!config.skills[skillName]) { + // Add new skill config with current timestamp + config.skills[skillName] = { + ...(skillConfig as any), + addedAt: Date.now(), + }; + addedCount++; + log.info( + `[SKILLS-CONFIG] Initialized config for example skill: ${skillName}` + ); + } + } + + if (addedCount > 0) { + await saveSkillConfig(userId, config); + log.info( + `[SKILLS-CONFIG] Added ${addedCount} example skill configs` + ); + } + } + } else { + log.warn( + `[SKILLS-CONFIG] Default config not found at: ${defaultConfigPath}` + ); + } + } catch (err) { + log.error( + '[SKILLS-CONFIG] Failed to load default config template:', + err + ); + // Continue anyway - user config is still valid + } + + log.info( + `[SKILLS-CONFIG] Config initialized with ${Object.keys(config.skills || {}).length} skills` + ); + return { success: true, config }; + } catch (error: any) { + log.error('skill-config-init failed', error); + return { success: false, error: error?.message }; + } + }); + + ipcMain.handle( + 'skill-import-zip', + async ( + _event, + zipPathOrBuffer: string | Buffer | ArrayBuffer | Uint8Array + ) => { + const isBufferLike = + Buffer.isBuffer(zipPathOrBuffer) || + zipPathOrBuffer instanceof ArrayBuffer || + zipPathOrBuffer instanceof Uint8Array; + if (isBufferLike) { + const buf = Buffer.isBuffer(zipPathOrBuffer) + ? zipPathOrBuffer + : Buffer.from(zipPathOrBuffer as ArrayBuffer); + const tempPath = path.join( + os.tmpdir(), + `eigent-skill-import-${Date.now()}.zip` + ); + try { + await fsp.writeFile(tempPath, buf); + const result = await importSkillsFromZip(tempPath); + return result; + } finally { + await fsp.unlink(tempPath).catch(() => {}); + } + } + return importSkillsFromZip(zipPathOrBuffer as string); + } + ); + // ==================== read file handler ==================== ipcMain.handle('read-file', async (event, filePath: string) => { try { @@ -1419,6 +1784,7 @@ const ensureEigentDirectories = () => { path.join(eigentBase, 'cache'), path.join(eigentBase, 'venvs'), path.join(eigentBase, 'runtime'), + path.join(eigentBase, 'skills'), ]; for (const dir of requiredDirs) { @@ -1431,6 +1797,72 @@ const ensureEigentDirectories = () => { log.info('.eigent directory structure ensured'); }; +// ==================== skills (used at startup and by IPC) ==================== +const SKILLS_ROOT = path.join(os.homedir(), '.eigent', 'skills'); +const SKILL_FILE = 'SKILL.md'; + +const getExampleSkillsSourceDir = (): string => + app.isPackaged + ? path.join(process.resourcesPath, 'example-skills') + : path.join(app.getAppPath(), 'resources', 'example-skills'); + +async function seedDefaultSkillsIfEmpty(): Promise { + if (!existsSync(SKILLS_ROOT)) return; + const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); + const hasAnySkill = entries.some( + (e) => e.isDirectory() && !e.name.startsWith('.') + ); + if (hasAnySkill) return; + const exampleDir = getExampleSkillsSourceDir(); + if (!existsSync(exampleDir)) { + log.warn('Example skills source dir missing:', exampleDir); + return; + } + const sourceEntries = await fsp.readdir(exampleDir, { withFileTypes: true }); + for (const e of sourceEntries) { + if (!e.isDirectory() || e.name.startsWith('.')) continue; + const skillMd = path.join(exampleDir, e.name, SKILL_FILE); + if (!existsSync(skillMd)) continue; + const content = await fsp.readFile(skillMd, 'utf-8'); + const destDir = path.join(SKILLS_ROOT, e.name); + await fsp.mkdir(destDir, { recursive: true }); + await fsp.writeFile(path.join(destDir, SKILL_FILE), content, 'utf-8'); + } + log.info('Seeded default skills to ~/.eigent/skills from', exampleDir); +} + +async function importSkillsFromZip(zipPath: string): Promise<{ + success: boolean; + error?: string; +}> { + try { + if (!existsSync(zipPath)) { + return { success: false, error: 'Zip file does not exist' }; + } + const ext = path.extname(zipPath).toLowerCase(); + if (ext !== '.zip') { + return { success: false, error: 'Only .zip files are supported' }; + } + if (!existsSync(SKILLS_ROOT)) { + await fsp.mkdir(SKILLS_ROOT, { recursive: true }); + } + const directory = await unzipper.Open.file(zipPath); + for (const file of directory.files as any[]) { + if (file.type === 'Directory') continue; + const destPath = path.join(SKILLS_ROOT, file.path); + const destDir = path.dirname(destPath); + await fsp.mkdir(destDir, { recursive: true }); + const content = await file.buffer(); + await fsp.writeFile(destPath, content); + } + log.info('Imported skills from zip into ~/.eigent/skills:', zipPath); + return { success: true }; + } catch (error: any) { + log.error('importSkillsFromZip failed', error); + return { success: false, error: error?.message || String(error) }; + } +} + // ==================== Shared backend startup logic ==================== // Starts backend after installation completes // Used by both initial startup and retry flows @@ -1456,6 +1888,7 @@ async function createWindow() { // Ensure .eigent directories exist before anything else ensureEigentDirectories(); + await seedDefaultSkillsIfEmpty(); log.info( `[PROJECT BROWSER WINDOW] Creating BrowserWindow which will start Chrome with CDP on port ${browser_port}` diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 5d7896db7..edd0c74a4 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -155,6 +155,30 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('get-project-folder-path', email, projectId), openInIDE: (folderPath: string, ide: string) => ipcRenderer.invoke('open-in-ide', folderPath, ide), + // Skills + getSkillsDir: () => ipcRenderer.invoke('get-skills-dir'), + skillsScan: () => ipcRenderer.invoke('skills-scan'), + skillWrite: (skillDirName: string, content: string) => + ipcRenderer.invoke('skill-write', skillDirName, content), + skillDelete: (skillDirName: string) => + ipcRenderer.invoke('skill-delete', skillDirName), + skillRead: (filePath: string) => ipcRenderer.invoke('skill-read', filePath), + skillListFiles: (skillDirName: string) => + ipcRenderer.invoke('skill-list-files', skillDirName), + skillImportZip: (zipPathOrBuffer: string | ArrayBuffer) => + ipcRenderer.invoke('skill-import-zip', zipPathOrBuffer), + openSkillFolder: (skillName: string) => + ipcRenderer.invoke('open-skill-folder', skillName), + skillConfigInit: (userId: string) => + ipcRenderer.invoke('skill-config-init', userId), + skillConfigLoad: (userId: string) => + ipcRenderer.invoke('skill-config-load', userId), + skillConfigToggle: (userId: string, skillName: string, enabled: boolean) => + ipcRenderer.invoke('skill-config-toggle', userId, skillName, enabled), + skillConfigUpdate: (userId: string, skillName: string, skillConfig: any) => + ipcRenderer.invoke('skill-config-update', userId, skillName, skillConfig), + skillConfigDelete: (userId: string, skillName: string) => + ipcRenderer.invoke('skill-config-delete', userId, skillName), }); // --------- Preload scripts loading --------- diff --git a/resources/example-skills/code-reviewer/SKILL.md b/resources/example-skills/code-reviewer/SKILL.md new file mode 100644 index 000000000..15681191f --- /dev/null +++ b/resources/example-skills/code-reviewer/SKILL.md @@ -0,0 +1,46 @@ +--- +name: code-reviewer +description: Review code for quality, bugs, and improvements. Use when user wants code review or quality assessment. +--- + +# Code Reviewer + +Perform thorough code reviews focusing on quality and correctness. + +## Review Checklist + +**1.** **Correctness** - Does the code do what it's supposed to? +**2.** **Readability** - Is the code easy to understand? +**3.** **Performance** - Are there any obvious inefficiencies? +**4.** **Security** - Are there potential vulnerabilities? +**5.** **Best Practices** - Does it follow language conventions? + +## Output Format + +```markdown +## Code Review Summary + +**Overall Assessment**: [Good/Needs Work/Critical Issues] + +### Issues Found + +| Severity | Line | Issue | Suggestion | +| -------- | ---- | ----- | ---------- | +| High | 42 | ... | ... | + +### Positive Aspects + +- [What's done well] + +### Recommendations + +1. [Priority fix 1] +2. [Priority fix 2] +``` + +## Severity Levels + +- **Critical**: Security issues, data loss risks +- **High**: Bugs, incorrect logic +- **Medium**: Performance issues, code smells +- **Low**: Style issues, minor improvements diff --git a/resources/example-skills/data-analyzer/SKILL.md b/resources/example-skills/data-analyzer/SKILL.md new file mode 100644 index 000000000..82f3b7423 --- /dev/null +++ b/resources/example-skills/data-analyzer/SKILL.md @@ -0,0 +1,40 @@ +--- +name: data-analyzer +description: Analyze datasets and extract insights. Use when user needs to understand data patterns, statistics, or trends. +--- + +# Data Analyzer + +Analyze data and provide statistical insights. + +## Workflow + +**1.** Load and inspect the data structure +**2.** Compute basic statistics (mean, median, std, min, max) +**3.** Identify patterns and anomalies +**4.** Summarize key findings + +## Output Format + +Provide analysis in this structure: + +``` +## Data Overview +- Total records: X +- Columns: [list] + +## Key Statistics +| Metric | Value | +|--------|-------| +| ... | ... | + +## Insights +- Finding 1 +- Finding 2 +``` + +## Guidelines + +- Always validate data types before analysis +- Handle missing values explicitly +- Report confidence levels for statistical claims diff --git a/resources/example-skills/data-analyzer/scripts/analyze.py b/resources/example-skills/data-analyzer/scripts/analyze.py new file mode 100644 index 000000000..1230a54da --- /dev/null +++ b/resources/example-skills/data-analyzer/scripts/analyze.py @@ -0,0 +1,35 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Sample analysis script for data-analyzer skill.""" + + +def analyze(data): + """Analyze data and return statistics.""" + if not data: + return {"error": "No data provided"} + return { + "count": len(data), + "sum": sum(data), + "mean": sum(data) / len(data), + "min": min(data), + "max": max(data), + } + + +if __name__ == "__main__": + # Example usage + sample_data = [10, 20, 30, 40, 50] + result = analyze(sample_data) + print(f"Analysis result: {result}") diff --git a/resources/example-skills/default-config.json b/resources/example-skills/default-config.json new file mode 100644 index 000000000..c1d19e2a7 --- /dev/null +++ b/resources/example-skills/default-config.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "description": "Default configuration template for example skills. Each skill is enabled by default with global scope (all agents, including future ones).", + "skills": { + "code-reviewer": { + "enabled": true, + "scope": { + "isGlobal": true, + "selectedAgents": [] + }, + "addedAt": 0, + "isExample": true + }, + "data-analyzer": { + "enabled": true, + "scope": { + "isGlobal": true, + "selectedAgents": [] + }, + "addedAt": 0, + "isExample": true + }, + "report-writer": { + "enabled": true, + "scope": { + "isGlobal": true, + "selectedAgents": [] + }, + "addedAt": 0, + "isExample": true + } + } +} diff --git a/resources/example-skills/report-writer/SKILL.md b/resources/example-skills/report-writer/SKILL.md new file mode 100644 index 000000000..2443fd9cd --- /dev/null +++ b/resources/example-skills/report-writer/SKILL.md @@ -0,0 +1,50 @@ +--- +name: report-writer +description: Generate professional reports from analysis results. Use when user needs to create formatted documents summarizing findings. +--- + +# Report Writer + +Transform analysis results into professional reports. + +## Report Structure + +**1.** **Executive Summary** - Key findings in 2-3 sentences +**2.** **Methodology** - How the analysis was performed +**3.** **Results** - Detailed findings with visualizations +**4.** **Recommendations** - Actionable next steps +**5.** **Appendix** - Raw data and technical details + +## Formatting Guidelines + +- Use clear headings and subheadings +- Include tables for numerical data +- Add bullet points for lists +- Keep paragraphs concise (3-4 sentences max) + +## Tone + +- Professional but accessible +- Data-driven claims with evidence +- Avoid jargon unless necessary + +## Example Output + +```markdown +# Analysis Report: [Title] + +## Executive Summary + +[2-3 sentence overview of key findings] + +## Results + +### Finding 1 + +[Description with supporting data] + +## Recommendations + +1. [Action item 1] +2. [Action item 2] +``` diff --git a/server/README_CN.md b/server/README_CN.md index 7e9ce222b..f0fb305b6 100644 --- a/server/README_CN.md +++ b/server/README_CN.md @@ -20,7 +20,7 @@ 说明:上述数据均保存在 Docker 中的本地 PostgreSQL 卷中(见“数据持久化”),不经我们云端。若你配置了外部模型或远程 MCP,则相应请求会发往你指定的第三方服务。 -______________________________________________________________________ +--- ### 快速开始(Docker 推荐) @@ -93,7 +93,7 @@ docker logs -f eigent_postgres | cat 提示:若拉取镜像缓慢,可在 Docker Desktop 配置国内镜像加速后重试。 -______________________________________________________________________ +--- ### 开发模式(可选) @@ -118,7 +118,7 @@ ______________________________________________________________________ uv run uvicorn main:api --reload --port 3001 --host 0.0.0.0 ``` -______________________________________________________________________ +--- ### 其它 diff --git a/server/README_EN.md b/server/README_EN.md index 822421465..32e95d144 100644 --- a/server/README_EN.md +++ b/server/README_EN.md @@ -20,7 +20,7 @@ Note: All the above data is stored in the local PostgreSQL volume in Docker (see “Data Persistence” below). If you configure external models or remote MCP, requests go to the third-party services you specify. -______________________________________________________________________ +--- ### Quick Start (Docker) @@ -91,7 +91,7 @@ docker logs -f eigent_api | cat docker logs -f eigent_postgres | cat ``` -______________________________________________________________________ +--- ### Developer Mode (Optional) @@ -110,7 +110,7 @@ export database_url=postgresql://postgres:123456@localhost:5432/eigent uv run uvicorn main:api --reload --port 3001 --host 0.0.0.0 ``` -______________________________________________________________________ +--- ### Others diff --git a/server/README_PT-BR.md b/server/README_PT-BR.md index 83beefda3..44050a626 100644 --- a/server/README_PT-BR.md +++ b/server/README_PT-BR.md @@ -20,7 +20,7 @@ Nota: Todos os dados acima são armazenados no volume PostgreSQL local no Docker (veja "Persistência de Dados" abaixo). Se você configurar modelos externos ou MCP remoto, as solicitações vão para os serviços de terceiros que você especificar. -______________________________________________________________________ +--- ### Início Rápido (Docker) @@ -91,7 +91,7 @@ docker logs -f eigent_api | cat docker logs -f eigent_postgres | cat ``` -______________________________________________________________________ +--- ### Modo Desenvolvedor (Opcional) @@ -110,7 +110,7 @@ export database_url=postgresql://postgres:123456@localhost:5432/eigent uv run uvicorn main:api --reload --port 3001 --host 0.0.0.0 ``` -______________________________________________________________________ +--- ### Outros diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx index bf0183cf3..d99fb0bea 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -13,11 +13,35 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { cn } from '@/lib/utils'; -import { Copy, FileText, Image } from 'lucide-react'; +import { Copy, FileText, Image, Sparkles } from 'lucide-react'; import { useRef, useState } from 'react'; import { Button } from '../../ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover'; +const SKILL_TAG_REGEX = /\{\{([^}]+)\}\}/g; + +function parseContentWithSkillTags( + content: string +): Array<{ type: 'text'; value: string } | { type: 'skill'; name: string }> { + const nodes: Array< + { type: 'text'; value: string } | { type: 'skill'; name: string } + > = []; + let lastIndex = 0; + let m: RegExpExecArray | null; + SKILL_TAG_REGEX.lastIndex = 0; + while ((m = SKILL_TAG_REGEX.exec(content)) !== null) { + if (m.index > lastIndex) { + nodes.push({ type: 'text', value: content.slice(lastIndex, m.index) }); + } + nodes.push({ type: 'skill', name: m[1].trim() }); + lastIndex = m.index + m[0].length; + } + if (lastIndex < content.length) { + nodes.push({ type: 'text', value: content.slice(lastIndex) }); + } + return nodes.length > 0 ? nodes : [{ type: 'text', value: content }]; +} + interface UserMessageCardProps { id: string; content: string; @@ -66,6 +90,13 @@ export function UserMessageCard({ return ; }; + const handleOpenSkillFolder = (skillName: string) => { + window.electronAPI?.openSkillFolder?.(skillName); + }; + + const contentNodes = parseContentWithSkillTags(content); + const hasSkillTags = contentNodes.some((n) => n.type === 'skill'); + return (
- {content} + {hasSkillTags + ? contentNodes.map((node, i) => + node.type === 'text' ? ( + {node.value} + ) : ( + + ) + ) + : content}
{attaches && attaches.length > 0 && (
diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 495b2fcd9..5734d3727 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -156,8 +156,9 @@ export default function ChatBox(): JSX.Element { // .catch((err) => console.error("Failed to fetch settings:", err)); // } // }, [privacyDialogOpen]); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const share_token = searchParams.get('share_token'); + const skill_prompt = searchParams.get('skill_prompt'); const [loading, setLoading] = useState(false); const [isReplayLoading, setIsReplayLoading] = useState(false); @@ -353,6 +354,17 @@ export default function ChatBox(): JSX.Element { } }, [share_token, isConfigLoaded, isPrivacyLoaded, handleSendShare]); + // Handle skill_prompt from URL - pre-fill message when navigating from Skills page + useEffect(() => { + if (skill_prompt) { + setMessage(skill_prompt); + // Clear the skill_prompt param from URL after setting the message + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete('skill_prompt'); + setSearchParams(newSearchParams, { replace: true }); + } + }, [skill_prompt, searchParams, setSearchParams]); + useEffect(() => { if (!chatStore) return; console.log('ChatStore Data: ', chatStore); diff --git a/src/components/Navigation/index.tsx b/src/components/Navigation/index.tsx index e016821b9..ff3f0e157 100644 --- a/src/components/Navigation/index.tsx +++ b/src/components/Navigation/index.tsx @@ -58,11 +58,11 @@ export function VerticalNavigation({ value={value} defaultValue={initial} onValueChange={onValueChange} - className={cn('flex w-full gap-4', className)} + className={cn('flex-1 w-full', className)} > diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index 2a1c5cf1d..b74f60758 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -12,24 +12,161 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Search } from 'lucide-react'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Search, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +export type SearchInputVariant = 'default' | 'icon'; + interface SearchInputProps { value: string; onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + variant?: SearchInputVariant; + /** Optional: called when user presses Enter in the field (e.g. to submit search) */ + onSearch?: () => void; + /** Tooltip for the search icon button (icon variant). Defaults to agents.search-tooltip */ + searchTooltip?: string; + /** Tooltip for the clear (X) button (icon variant). Defaults to agents.clear-search-tooltip */ + clearTooltip?: string; } -export default function SearchInput({ value, onChange }: SearchInputProps) { +const COLLAPSED_WIDTH = 40; +const EXPANDED_WIDTH = 240; + +export default function SearchInput({ + value, + onChange, + placeholder, + variant = 'default', + onSearch, + searchTooltip, + clearTooltip, +}: SearchInputProps) { const { t } = useTranslation(); + const inputRef = useRef(null); + const [userExpanded, setUserExpanded] = useState(false); + const isExpanded = userExpanded || value.length > 0; + + const expand = useCallback(() => { + setUserExpanded(true); + }, []); + + const collapse = useCallback(() => { + setUserExpanded(false); + onChange({ target: { value: '' } } as React.ChangeEvent); + }, [onChange]); + + useEffect(() => { + if (userExpanded && inputRef.current) { + const id = requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + return () => cancelAnimationFrame(id); + } + }, [userExpanded]); + + const searchLabel = searchTooltip ?? t('agents.search-tooltip'); + const clearLabel = clearTooltip ?? t('agents.clear-search-tooltip'); + const place = placeholder ?? t('setting.search-mcp'); + + if (variant === 'icon') { + return ( + + + {!isExpanded ? ( + + + + + + ) : ( + + + + + { + if (value.length === 0) setUserExpanded(false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onSearch?.(); + } + }} + className="h-6 min-w-0 flex-1 bg-transparent pl-2 text-label-sm text-text-heading outline-none placeholder:text-text-label" + /> + + + + + )} + + + ); + } + return (
} />
diff --git a/src/components/WorkFlow/agents.tsx b/src/components/WorkFlow/agents.tsx new file mode 100644 index 000000000..0e62d337a --- /dev/null +++ b/src/components/WorkFlow/agents.tsx @@ -0,0 +1,106 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { Bird, CodeXml, FileText, Globe, Image } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export type WorkflowAgentType = + | 'developer_agent' + | 'browser_agent' + | 'document_agent' + | 'multi_modal_agent' + | 'social_media_agent'; + +export interface AgentDisplayInfo { + name: string; + icon: ReactNode; + textColor: string; + bgColor: string; + shapeColor: string; + borderColor: string; + bgColorLight: string; +} + +export const agentMap: Record = { + developer_agent: { + name: 'Developer Agent', + icon: , + textColor: 'text-text-developer', + bgColor: 'bg-bg-fill-coding-active', + shapeColor: 'bg-bg-fill-coding-default', + borderColor: 'border-bg-fill-coding-active', + bgColorLight: 'bg-emerald-200', + }, + browser_agent: { + name: 'Browser Agent', + icon: , + textColor: 'text-blue-700', + bgColor: 'bg-bg-fill-browser-active', + shapeColor: 'bg-bg-fill-browser-default', + borderColor: 'border-bg-fill-browser-active', + bgColorLight: 'bg-blue-200', + }, + document_agent: { + name: 'Document Agent', + icon: , + textColor: 'text-yellow-700', + bgColor: 'bg-bg-fill-writing-active', + shapeColor: 'bg-bg-fill-writing-default', + borderColor: 'border-bg-fill-writing-active', + bgColorLight: 'bg-yellow-200', + }, + multi_modal_agent: { + name: 'Multi Modal Agent', + icon: , + textColor: 'text-fuchsia-700', + bgColor: 'bg-bg-fill-multimodal-active', + shapeColor: 'bg-bg-fill-multimodal-default', + borderColor: 'border-bg-fill-multimodal-active', + bgColorLight: 'bg-fuchsia-200', + }, + social_media_agent: { + name: 'Social Media Agent', + icon: , + textColor: 'text-purple-700', + bgColor: 'bg-violet-700', + shapeColor: 'bg-violet-300', + borderColor: 'border-violet-700', + bgColorLight: 'bg-purple-50', + }, +}; + +/** Ordered list of workflow agents (name + icon) for use in skill scope and elsewhere. */ +export const WORKFLOW_AGENT_LIST: { name: string; icon: ReactNode }[] = [ + { name: agentMap.developer_agent.name, icon: agentMap.developer_agent.icon }, + { name: agentMap.browser_agent.name, icon: agentMap.browser_agent.icon }, + { name: agentMap.document_agent.name, icon: agentMap.document_agent.icon }, + { + name: agentMap.multi_modal_agent.name, + icon: agentMap.multi_modal_agent.icon, + }, + { + name: agentMap.social_media_agent.name, + icon: agentMap.social_media_agent.icon, + }, +]; + +/** Get display info (name + icon) by agent name; returns undefined if not a workflow agent. */ +export function getWorkflowAgentDisplay( + agentName: string +): { name: string; icon: ReactNode } | undefined { + const entry = WORKFLOW_AGENT_LIST.find( + (a) => a.name.toLowerCase() === agentName.toLowerCase() + ); + return entry; +} diff --git a/src/components/WorkFlow/node.tsx b/src/components/WorkFlow/node.tsx index eafa42941..288cce69d 100644 --- a/src/components/WorkFlow/node.tsx +++ b/src/components/WorkFlow/node.tsx @@ -23,17 +23,12 @@ import { } from '@/types/constants'; import { Handle, NodeResizer, Position, useReactFlow } from '@xyflow/react'; import { - Bird, Bot, Circle, CircleCheckBig, CircleSlash, CircleSlash2, - CodeXml, Ellipsis, - FileText, - Globe, - Image, LoaderCircle, SquareChevronLeft, SquareCode, @@ -52,6 +47,7 @@ import { } from '../ui/popover'; import ShinyText from '../ui/ShinyText/ShinyText'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { agentMap } from './agents'; import { MarkDown } from './MarkDown'; interface NodeProps { @@ -295,54 +291,6 @@ export function Node({ id, data }: NodeProps) { data.onExpandChange(id, !isExpanded); }; - const agentMap = { - developer_agent: { - name: 'Developer Agent', - icon: , - textColor: 'text-text-developer', - bgColor: 'bg-bg-fill-coding-active', - shapeColor: 'bg-bg-fill-coding-default', - borderColor: 'border-bg-fill-coding-active', - bgColorLight: 'bg-emerald-200', - }, - browser_agent: { - name: 'Browser Agent', - icon: , - textColor: 'text-blue-700', - bgColor: 'bg-bg-fill-browser-active', - shapeColor: 'bg-bg-fill-browser-default', - borderColor: 'border-bg-fill-browser-active', - bgColorLight: 'bg-blue-200', - }, - document_agent: { - name: 'Document Agent', - icon: , - textColor: 'text-yellow-700', - bgColor: 'bg-bg-fill-writing-active', - shapeColor: 'bg-bg-fill-writing-default', - borderColor: 'border-bg-fill-writing-active', - bgColorLight: 'bg-yellow-200', - }, - multi_modal_agent: { - name: 'Multi Modal Agent', - icon: , - textColor: 'text-fuchsia-700', - bgColor: 'bg-bg-fill-multimodal-active', - shapeColor: 'bg-bg-fill-multimodal-default', - borderColor: 'border-bg-fill-multimodal-active', - bgColorLight: 'bg-fuchsia-200', - }, - social_media_agent: { - name: 'Social Media Agent', - icon: , - textColor: 'text-purple-700', - bgColor: 'bg-violet-700', - shapeColor: 'bg-violet-300', - borderColor: 'border-violet-700', - bgColorLight: 'bg-purple-50', - }, - }; - const agentToolkits = { developer_agent: [ '# Terminal & Shell ', diff --git a/src/components/ui/alertDialog.tsx b/src/components/ui/alertDialog.tsx index f5bd4f900..354952ad7 100644 --- a/src/components/ui/alertDialog.tsx +++ b/src/components/ui/alertDialog.tsx @@ -55,7 +55,8 @@ export default function ConfirmModal({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="bg-white/5 z-100 alert-dialog fixed inset-0" + className="alert-dialog fixed inset-0 z-[99] bg-black/20" + style={{ backgroundColor: 'rgba(0, 0, 0, 0.2)' }} onClick={onClose} /> @@ -64,9 +65,9 @@ export default function ConfirmModal({ initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }} - className="alert-dialog-wrapper fixed max-w-md rounded-xl shadow-perfect" + className="alert-dialog-wrapper fixed left-1/2 top-1/2 z-[100] max-w-md rounded-xl -translate-x-1/2 -translate-y-1/2" > -
+
{title} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index d45c6c749..240b074c4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -46,6 +46,8 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +export type DialogOverlayVariant = 'default' | 'dark'; + // Size variants for dialog content const dialogContentVariants = cva( 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-0 border border-solid border-popup-border bg-popup-bg shadow-perfect duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl', @@ -72,6 +74,8 @@ interface DialogContentProps closeButtonClassName?: string; closeButtonIcon?: React.ReactNode; onClose?: () => void; + /** Overlay behind the dialog: 'default' (transparent) or 'dark' (black overlay) */ + overlayVariant?: DialogOverlayVariant; } const DialogContent = React.forwardRef< @@ -87,15 +91,27 @@ const DialogContent = React.forwardRef< closeButtonClassName, closeButtonIcon, onClose, + overlayVariant = 'default', ...props }, ref ) => ( - + {children} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index f081dfc23..77d93166e 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -13,56 +13,190 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { AnimatePresence, motion } from 'framer-motion'; import * as React from 'react'; import { cn } from '@/lib/utils'; +// Context for variant +const TabsContext = React.createContext<{ variant?: 'default' | 'outline' }>({ + variant: 'default', +}); + const Tabs = TabsPrimitive.Root; +type TabsListProps = React.ComponentPropsWithoutRef< + typeof TabsPrimitive.List +> & { + variant?: 'default' | 'outline'; +}; + const TabsList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + TabsListProps +>(({ className, variant = 'default', ...props }, ref) => { + const tabsListRef = React.useRef | null>(null) as React.MutableRefObject | null>; + const [sliderStyle, setSliderStyle] = React.useState({ left: 0, width: 0 }); + + // Update slider position when active tab changes + React.useLayoutEffect(() => { + if (variant !== 'outline' || !tabsListRef.current) return; + + const updateSlider = () => { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + const activeTab = tabsListRef.current?.querySelector( + '[data-state="active"][data-variant="outline"]' + ) as HTMLElement; + + if (activeTab && tabsListRef.current) { + const containerRect = tabsListRef.current.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + + setSliderStyle({ + left: tabRect.left - containerRect.left, + width: tabRect.width, + }); + } + }); + }; + + // Initial update + updateSlider(); + + // Watch for changes + const observer = new MutationObserver(updateSlider); + if (tabsListRef.current) { + observer.observe(tabsListRef.current, { + attributes: true, + attributeFilter: ['data-state'], + subtree: true, + }); + } + + // Also listen for resize + window.addEventListener('resize', updateSlider); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateSlider); + }; + }, [variant]); + + const combinedRef = React.useCallback( + (node: React.ElementRef | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref && 'current' in ref) { + ( + ref as React.MutableRefObject | null> + ).current = node; + } + tabsListRef.current = node; + }, + [ref] + ); + + return ( + +
+ + {variant === 'outline' && sliderStyle.width > 0 && ( + + )} +
+
+ ); +}); TabsList.displayName = TabsPrimitive.List.displayName; +type TabsTriggerProps = React.ComponentPropsWithoutRef< + typeof TabsPrimitive.Trigger +> & { + variant?: 'default' | 'outline'; +}; + const TabsTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + TabsTriggerProps +>(({ className, variant: propVariant, ...props }, ref) => { + const { variant: contextVariant } = React.useContext(TabsContext); + const variant = propVariant || contextVariant || 'default'; + + return ( + + ); +}); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, children, ...props }, ref) => { + return ( + + + + {children} + + + + ); +}); TabsContent.displayName = TabsPrimitive.Content.displayName; export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index fa4275ff1..a50e9b3fb 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -59,6 +59,7 @@ const ToggleGroupItem = React.forwardRef< variant: context.variant || variant, size: context.size || size, }), + 'bg-surface-primary border-border-disabled data-[state=on]:bg-surface-tertiary data-[state=on]:border-border-secondary', className )} {...props} diff --git a/src/i18n/locales/ar/agents.json b/src/i18n/locales/ar/agents.json new file mode 100644 index 000000000..521624d12 --- /dev/null +++ b/src/i18n/locales/ar/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "المهارات", + "memory": "الذاكرة", + "preview": "معاينة", + "skills-description": "أضف مهارات مخصصة لتوسيع قدرات الوكيل الخاص بك.", + "memory-description": "إدارة ذاكرة الوكيل وقاعدة المعرفة الخاصة به.", + "memory-coming-soon-description": "ستتيح ميزات الذاكرة لوكلائك تذكر المعلومات المهمة عبر الجلسات.", + "learn-more": "معرفة المزيد", + "search-skills": "البحث عن المهارات...", + "search-tooltip": "بحث", + "clear-search-tooltip": "مسح البحث", + "add": "إضافة", + "your-skills": "مهاراتك", + "example-skills": "مهارات نموذجية", + "no-skills-found": "لم يتم العثور على مهارات مطابقة.", + "no-your-skills": "لم تقم بإضافة أي مهارات بعد.", + "no-example-skills": "لا توجد مهارات نموذجية متاحة.", + "add-your-first-skill": "أضف مهارتك الأولى", + "added": "مضاف", + "global": "عام", + "partial-available": "متاح جزئياً (اختيار متعدد)", + "select-agents": "اختر الوكلاء", + "select-scope": "اختر النطاق", + "skill-scope": "نطاق المهارة", + "selected": "محدد", + "agents": "الوكلاء", + "no-agents-available": "لا يوجد وكلاء متاحون", + "try-in-chat": "جرب في المحادثة", + "add-skill": "إضافة مهارة", + "drag-and-drop": "اسحب وأفلت الملف هنا", + "or-click-to-browse": "أو انقر للاستعراض", + "file-requirements": "متطلبات الملف:", + "file-requirements-detail-1": "يجب أن يتضمن ملف .zip أو حزمة المهارات المرفوعة ملف SKILL.md في الدليل الجذر.", + "file-requirements-detail-2": "يجب أن يحدد ملف SKILL.md اسم المهارة والوصف باستخدام تنسيق YAML.", + "supported-formats": "التنسيقات المدعومة", + "max-file-size": "الحد الأقصى لحجم الملف", + "upload": "رفع", + "invalid-file-type": "نوع ملف غير صالح. يرجى رفع ملف .skill أو .md أو .txt أو .json.", + "file-too-large": "الملف كبير جداً. الحد الأقصى للحجم هو 1 ميجابايت.", + "file-read-error": "فشل في قراءة الملف. يرجى المحاولة مرة أخرى.", + "reupload-file": "إعادة رفع ملف", + "upload-error-invalid-format": "يجب أن يكون الملف .zip أو حزمة مهارة (.skill أو .md).", + "upload-error-invalid-yaml": "يجب أن يحدد SKILL.md الاسم والوصف بتنسيق YAML.", + "skill-added-success": "تمت إضافة المهارة بنجاح!", + "skill-add-error": "فشل في إضافة المهارة. يرجى المحاولة مرة أخرى.", + "custom-skill": "مهارة مخصصة", + "delete-skill": "حذف المهارة", + "delete-skill-confirmation": "هل أنت متأكد من أنك تريد حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.", + "skill-deleted-success": "تم حذف المهارة بنجاح!" +} diff --git a/src/i18n/locales/ar/index.ts b/src/i18n/locales/ar/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/ar/index.ts +++ b/src/i18n/locales/ar/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ar/layout.json b/src/i18n/locales/ar/layout.json index 75f758ab6..56ab973ce 100644 --- a/src/i18n/locales/ar/layout.json +++ b/src/i18n/locales/ar/layout.json @@ -31,8 +31,10 @@ "installation-failed": "فشل التثبيت", "projects": "المشاريع", "mcp-tools": "MCP والأدوات", + "connectors": "الموصلات", "browser": "المتصفح", "settings": "الإعدادات", + "general": "عام", "workers": "العمال", "triggers": "المحفزات", "new-project": "مشروع جديد", @@ -166,5 +168,6 @@ "days-ago": "أيام مضت", "delete-project": "حذف المشروع", "delete-project-confirmation": "هل أنت متأكد من أنك تريد حذف هذا المشروع وجميع مهامه؟ لا يمكن التراجع عن هذا الإجراء.", - "please-select-model": "يرجى اختيار نموذج في الإعدادات > النماذج للمتابعة." + "please-select-model": "يرجى اختيار نموذج في الإعدادات > النماذج للمتابعة.", + "capabilities": "القدرات" } diff --git a/src/i18n/locales/de/agents.json b/src/i18n/locales/de/agents.json new file mode 100644 index 000000000..18bc2dd25 --- /dev/null +++ b/src/i18n/locales/de/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Fähigkeiten", + "memory": "Speicher", + "preview": "Vorschau", + "skills-description": "Fügen Sie benutzerdefinierte Fähigkeiten hinzu, um die Funktionen Ihres Agenten zu erweitern.", + "memory-description": "Verwalten Sie den Speicher und die Wissensbasis Ihres Agenten.", + "memory-coming-soon-description": "Speicherfunktionen ermöglichen es Ihren Agenten, wichtige Informationen zwischen Sitzungen zu speichern.", + "learn-more": "Mehr erfahren", + "search-skills": "Fähigkeiten suchen...", + "search-tooltip": "Suchen", + "clear-search-tooltip": "Suche löschen", + "add": "Hinzufügen", + "your-skills": "Ihre Fähigkeiten", + "example-skills": "Beispiel-Fähigkeiten", + "no-skills-found": "Keine passenden Fähigkeiten gefunden.", + "no-your-skills": "Sie haben noch keine Fähigkeiten hinzugefügt.", + "no-example-skills": "Keine Beispiel-Fähigkeiten verfügbar.", + "add-your-first-skill": "Fügen Sie Ihre erste Fähigkeit hinzu", + "added": "Hinzugefügt", + "global": "Global", + "partial-available": "Teilweise verfügbar (Mehrfachauswahl)", + "select-agents": "Agenten auswählen", + "select-scope": "Bereich auswählen", + "skill-scope": "Fähigkeitsbereich", + "selected": "ausgewählt", + "agents": "Agenten", + "no-agents-available": "Keine Agenten verfügbar", + "try-in-chat": "Im Chat ausprobieren", + "add-skill": "Fähigkeit hinzufügen", + "drag-and-drop": "Datei hierher ziehen", + "or-click-to-browse": "oder klicken zum Durchsuchen", + "file-requirements": "Dateianforderungen:", + "file-requirements-detail-1": "Das hochgeladene .zip oder Skill-Paket muss eine SKILL.md Datei im Stammverzeichnis enthalten.", + "file-requirements-detail-2": "Die SKILL.md Datei muss den Skill-Namen und die Beschreibung im YAML-Format definieren.", + "supported-formats": "Unterstützte Formate", + "max-file-size": "Maximale Dateigröße", + "upload": "Hochladen", + "invalid-file-type": "Ungültiger Dateityp. Bitte laden Sie eine .skill, .md, .txt oder .json Datei hoch.", + "file-too-large": "Datei ist zu groß. Maximale Größe ist 1MB.", + "file-read-error": "Datei konnte nicht gelesen werden. Bitte versuchen Sie es erneut.", + "reupload-file": "Datei erneut hochladen", + "upload-error-invalid-format": "Datei muss ein .zip- oder Skill-Paket (.skill oder .md) sein.", + "upload-error-invalid-yaml": "SKILL.md muss name und description im YAML-Format definieren.", + "skill-added-success": "Fähigkeit erfolgreich hinzugefügt!", + "skill-add-error": "Fähigkeit konnte nicht hinzugefügt werden. Bitte versuchen Sie es erneut.", + "custom-skill": "Benutzerdefinierte Fähigkeit", + "delete-skill": "Fähigkeit löschen", + "delete-skill-confirmation": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "skill-deleted-success": "Fähigkeit erfolgreich gelöscht!" +} diff --git a/src/i18n/locales/de/index.ts b/src/i18n/locales/de/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/de/index.ts +++ b/src/i18n/locales/de/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/de/layout.json b/src/i18n/locales/de/layout.json index 4cc133adb..4e1b9f8b3 100644 --- a/src/i18n/locales/de/layout.json +++ b/src/i18n/locales/de/layout.json @@ -31,8 +31,10 @@ "installation-failed": "Installation fehlgeschlagen", "projects": "Projekte", "mcp-tools": "MCP & Tools", + "connectors": "Konnektoren", "browser": "Browser", "settings": "Einstellungen", + "general": "Allgemein", "workers": "Mitarbeiter", "triggers": "Trigger", "new-project": "Neues Projekt", @@ -166,5 +168,6 @@ "days-ago": "Tage zuvor", "delete-project": "Projekt löschen", "delete-project-confirmation": "Sind Sie sicher, dass Sie dieses Projekt und alle seine Aufgaben löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "please-select-model": "Bitte wählen Sie ein Modell unter Einstellungen > Modelle aus, um fortzufahren." + "please-select-model": "Bitte wählen Sie ein Modell unter Einstellungen > Modelle aus, um fortzufahren.", + "capabilities": "Fähigkeiten" } diff --git a/src/i18n/locales/en-us/agents.json b/src/i18n/locales/en-us/agents.json new file mode 100644 index 000000000..13f215331 --- /dev/null +++ b/src/i18n/locales/en-us/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Skills", + "memory": "Memory", + "preview": "Preview", + "skills-description": "Add custom skills to extend your agent's capabilities.", + "memory-description": "Manage your agent's memory and knowledge base.", + "memory-coming-soon-description": "Memory features will allow your agents to remember important information across sessions.", + "learn-more": "Learn more", + "search-skills": "Search skills...", + "search-tooltip": "Search", + "clear-search-tooltip": "Clear search", + "add": "Add", + "your-skills": "Your skills", + "example-skills": "Example skills", + "no-skills-found": "No skills found matching your search.", + "no-your-skills": "You haven't added any skills yet.", + "no-example-skills": "No example skills available.", + "add-your-first-skill": "Add your first skill", + "added": "Added", + "global": "Global", + "partial-available": "Partial Available (Multi-select)", + "select-agents": "Select agents", + "select-scope": "Select scope", + "skill-scope": "Skill Scope", + "selected": "selected", + "agents": "Agents", + "no-agents-available": "No agents available", + "try-in-chat": "Try in chat", + "add-skill": "Add Skill", + "drag-and-drop": "Drag and drop your file here", + "or-click-to-browse": "or click to browse", + "file-requirements": "File requirements:", + "file-requirements-detail-1": "The uploaded .zip or skill package must include a SKILL.md file located in the root directory.", + "file-requirements-detail-2": "The SKILL.md file must define the skill name and description using YAML format.", + "supported-formats": "Supported formats", + "max-file-size": "Maximum file size", + "upload": "Upload", + "invalid-file-type": "Invalid file type. Please upload a .skill, .md, .txt, or .json file.", + "file-too-large": "File is too large. Maximum size is 1MB.", + "file-read-error": "Failed to read file. Please try again.", + "reupload-file": "Click to reupload a file", + "upload-error-invalid-format": "File must be a .zip or skill package (.skill or .md).", + "upload-error-invalid-yaml": "SKILL.md must define name and description using YAML format.", + "skill-added-success": "Skill added successfully!", + "skill-add-error": "Failed to add skill. Please try again.", + "custom-skill": "Custom skill", + "delete-skill": "Delete Skill", + "delete-skill-confirmation": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "skill-deleted-success": "Skill deleted successfully!" +} diff --git a/src/i18n/locales/en-us/index.ts b/src/i18n/locales/en-us/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/en-us/index.ts +++ b/src/i18n/locales/en-us/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index 786103b67..3f6c609da 100644 --- a/src/i18n/locales/en-us/layout.json +++ b/src/i18n/locales/en-us/layout.json @@ -32,8 +32,10 @@ "backend-startup-failed": "Backend Startup Failed", "projects": "Projects", "mcp-tools": "MCP & Tools", + "connectors": "Connectors", "browser": "Browser", "settings": "Settings", + "general": "General", "workers": "Workers", "triggers": "Triggers", "new-project": "New Project", @@ -168,5 +170,6 @@ "days-ago": "days ago", "delete-project": "Delete Project", "delete-project-confirmation": "Are you sure you want to delete this project and all its tasks? This action cannot be undone.", - "please-select-model": "Please select a model in Settings > Models to continue." + "please-select-model": "Please select a model in Settings > Models to continue.", + "capabilities": "Capabilities" } diff --git a/src/i18n/locales/en-us/setting.json b/src/i18n/locales/en-us/setting.json index f04446e47..7b3a2c220 100644 --- a/src/i18n/locales/en-us/setting.json +++ b/src/i18n/locales/en-us/setting.json @@ -417,5 +417,6 @@ "preferred-ide": "Preferred IDE", "preferred-ide-description": "Choose which application to use when opening agent project folders.", - "system-file-manager": "System File Manager" + "system-file-manager": "System File Manager", + "agents": "Agents" } diff --git a/src/i18n/locales/es/agents.json b/src/i18n/locales/es/agents.json new file mode 100644 index 000000000..c16ad29b1 --- /dev/null +++ b/src/i18n/locales/es/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Habilidades", + "memory": "Memoria", + "preview": "Vista previa", + "skills-description": "Agregue habilidades personalizadas para ampliar las capacidades de su agente.", + "memory-description": "Administre la memoria y la base de conocimientos de su agente.", + "memory-coming-soon-description": "Las funciones de memoria permitirán que sus agentes recuerden información importante entre sesiones.", + "learn-more": "Más información", + "search-skills": "Buscar habilidades...", + "search-tooltip": "Buscar", + "clear-search-tooltip": "Borrar búsqueda", + "add": "Agregar", + "your-skills": "Sus habilidades", + "example-skills": "Habilidades de ejemplo", + "no-skills-found": "No se encontraron habilidades coincidentes.", + "no-your-skills": "Aún no ha agregado ninguna habilidad.", + "no-example-skills": "No hay habilidades de ejemplo disponibles.", + "add-your-first-skill": "Agregue su primera habilidad", + "added": "Agregado", + "global": "Global", + "partial-available": "Parcialmente disponible (selección múltiple)", + "select-agents": "Seleccionar agentes", + "select-scope": "Seleccionar alcance", + "skill-scope": "Alcance de habilidad", + "selected": "seleccionados", + "agents": "Agentes", + "no-agents-available": "No hay agentes disponibles", + "try-in-chat": "Probar en chat", + "add-skill": "Agregar habilidad", + "drag-and-drop": "Arrastre y suelte el archivo aquí", + "or-click-to-browse": "o haga clic para explorar", + "file-requirements": "Requisitos del archivo:", + "file-requirements-detail-1": "El .zip o paquete de habilidades cargado debe incluir un archivo SKILL.md en el directorio raíz.", + "file-requirements-detail-2": "El archivo SKILL.md debe definir el nombre y la descripción de la habilidad usando formato YAML.", + "supported-formats": "Formatos admitidos", + "max-file-size": "Tamaño máximo del archivo", + "upload": "Cargar", + "invalid-file-type": "Tipo de archivo no válido. Por favor cargue un archivo .skill, .md, .txt o .json.", + "file-too-large": "El archivo es demasiado grande. El tamaño máximo es 1MB.", + "file-read-error": "Error al leer el archivo. Por favor intente de nuevo.", + "reupload-file": "Volver a subir un archivo", + "upload-error-invalid-format": "El archivo debe ser un .zip o paquete de habilidad (.skill o .md).", + "upload-error-invalid-yaml": "SKILL.md debe definir name y description en formato YAML.", + "skill-added-success": "¡Habilidad agregada exitosamente!", + "skill-add-error": "Error al agregar la habilidad. Por favor intente de nuevo.", + "custom-skill": "Habilidad personalizada", + "delete-skill": "Eliminar habilidad", + "delete-skill-confirmation": "¿Está seguro de que desea eliminar \"{{name}}\"? Esta acción no se puede deshacer.", + "skill-deleted-success": "¡Habilidad eliminada exitosamente!" +} diff --git a/src/i18n/locales/es/index.ts b/src/i18n/locales/es/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/es/index.ts +++ b/src/i18n/locales/es/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/es/layout.json b/src/i18n/locales/es/layout.json index 3f700d514..db8414254 100644 --- a/src/i18n/locales/es/layout.json +++ b/src/i18n/locales/es/layout.json @@ -31,8 +31,10 @@ "installation-failed": "Error al instalar", "projects": "Proyectos", "mcp-tools": "MCP & Herramientas", + "connectors": "Conectores", "browser": "Navegador", "settings": "Ajustes", + "general": "General", "workers": "Trabajadores", "triggers": "Disparadores", "new-project": "Nuevo Proyecto", @@ -166,5 +168,6 @@ "days-ago": "días atrás", "delete-project": "Eliminar Proyecto", "delete-project-confirmation": "¿Estás seguro de que quieres eliminar este proyecto y todas sus tareas? Esta acción no se puede deshacer.", - "please-select-model": "Por favor, selecciona un modelo en Configuración > Modelos para continuar." + "please-select-model": "Por favor, selecciona un modelo en Configuración > Modelos para continuar.", + "capabilities": "Capacidades" } diff --git a/src/i18n/locales/fr/agents.json b/src/i18n/locales/fr/agents.json new file mode 100644 index 000000000..45a457600 --- /dev/null +++ b/src/i18n/locales/fr/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Compétences", + "memory": "Mémoire", + "preview": "Aperçu", + "skills-description": "Ajoutez des compétences personnalisées pour étendre les capacités de votre agent.", + "memory-description": "Gérez la mémoire et la base de connaissances de votre agent.", + "memory-coming-soon-description": "Les fonctionnalités de mémoire permettront à vos agents de mémoriser des informations importantes entre les sessions.", + "learn-more": "En savoir plus", + "search-skills": "Rechercher des compétences...", + "search-tooltip": "Rechercher", + "clear-search-tooltip": "Effacer la recherche", + "add": "Ajouter", + "your-skills": "Vos compétences", + "example-skills": "Exemples de compétences", + "no-skills-found": "Aucune compétence correspondante trouvée.", + "no-your-skills": "Vous n'avez pas encore ajouté de compétences.", + "no-example-skills": "Aucun exemple de compétence disponible.", + "add-your-first-skill": "Ajoutez votre première compétence", + "added": "Ajouté", + "global": "Global", + "partial-available": "Partiellement disponible (sélection multiple)", + "select-agents": "Sélectionner les agents", + "select-scope": "Sélectionner la portée", + "skill-scope": "Portée de la compétence", + "selected": "sélectionnés", + "agents": "Agents", + "no-agents-available": "Aucun agent disponible", + "try-in-chat": "Essayer dans le chat", + "add-skill": "Ajouter une compétence", + "drag-and-drop": "Glissez-déposez votre fichier ici", + "or-click-to-browse": "ou cliquez pour parcourir", + "file-requirements": "Exigences du fichier :", + "file-requirements-detail-1": "Le .zip ou le package de compétences téléchargé doit inclure un fichier SKILL.md situé dans le répertoire racine.", + "file-requirements-detail-2": "Le fichier SKILL.md doit définir le nom et la description de la compétence au format YAML.", + "supported-formats": "Formats pris en charge", + "max-file-size": "Taille maximale du fichier", + "upload": "Télécharger", + "invalid-file-type": "Type de fichier non valide. Veuillez télécharger un fichier .skill, .md, .txt ou .json.", + "file-too-large": "Le fichier est trop volumineux. La taille maximale est de 1 Mo.", + "file-read-error": "Échec de la lecture du fichier. Veuillez réessayer.", + "reupload-file": "Téléverser à nouveau un fichier", + "upload-error-invalid-format": "Le fichier doit être un .zip ou un package de compétence (.skill ou .md).", + "upload-error-invalid-yaml": "SKILL.md doit définir name et description au format YAML.", + "skill-added-success": "Compétence ajoutée avec succès !", + "skill-add-error": "Échec de l'ajout de la compétence. Veuillez réessayer.", + "custom-skill": "Compétence personnalisée", + "delete-skill": "Supprimer la compétence", + "delete-skill-confirmation": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.", + "skill-deleted-success": "Compétence supprimée avec succès !" +} diff --git a/src/i18n/locales/fr/index.ts b/src/i18n/locales/fr/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/fr/index.ts +++ b/src/i18n/locales/fr/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index d2415b021..4ad84d348 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -31,8 +31,10 @@ "installation-failed": "Échec de l'installation", "projects": "Projets", "mcp-tools": "MCP & Outils", + "connectors": "Connecteurs", "browser": "Navigateur", "settings": "Paramètres", + "general": "Général", "workers": "Travailleurs", "triggers": "Déclencheurs", "new-project": "Nouveau Projet", @@ -166,5 +168,6 @@ "days-ago": "jours auparavant", "delete-project": "Supprimer le Projet", "delete-project-confirmation": "Êtes-vous sûr de vouloir supprimer ce projet et toutes ses tâches ? Cette action ne peut pas être annulée.", - "please-select-model": "Veuillez sélectionner un modèle dans Paramètres > Modèles pour continuer." + "please-select-model": "Veuillez sélectionner un modèle dans Paramètres > Modèles pour continuer.", + "capabilities": "Capacités" } diff --git a/src/i18n/locales/it/agents.json b/src/i18n/locales/it/agents.json new file mode 100644 index 000000000..b9855f585 --- /dev/null +++ b/src/i18n/locales/it/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Competenze", + "memory": "Memoria", + "preview": "Anteprima", + "skills-description": "Aggiungi competenze personalizzate per estendere le capacità del tuo agente.", + "memory-description": "Gestisci la memoria e la base di conoscenza del tuo agente.", + "memory-coming-soon-description": "Le funzionalità di memoria permetteranno ai tuoi agenti di ricordare informazioni importanti tra le sessioni.", + "learn-more": "Scopri di più", + "search-skills": "Cerca competenze...", + "search-tooltip": "Cerca", + "clear-search-tooltip": "Cancella ricerca", + "add": "Aggiungi", + "your-skills": "Le tue competenze", + "example-skills": "Competenze di esempio", + "no-skills-found": "Nessuna competenza corrispondente trovata.", + "no-your-skills": "Non hai ancora aggiunto competenze.", + "no-example-skills": "Nessuna competenza di esempio disponibile.", + "add-your-first-skill": "Aggiungi la tua prima competenza", + "added": "Aggiunto", + "global": "Globale", + "partial-available": "Parzialmente disponibile (selezione multipla)", + "select-agents": "Seleziona agenti", + "select-scope": "Seleziona ambito", + "skill-scope": "Ambito competenza", + "selected": "selezionati", + "agents": "Agenti", + "no-agents-available": "Nessun agente disponibile", + "try-in-chat": "Prova in chat", + "add-skill": "Aggiungi competenza", + "drag-and-drop": "Trascina e rilascia il file qui", + "or-click-to-browse": "o clicca per sfogliare", + "file-requirements": "Requisiti del file:", + "file-requirements-detail-1": "Il .zip o il pacchetto di competenze caricato deve includere un file SKILL.md nella directory principale.", + "file-requirements-detail-2": "Il file SKILL.md deve definire il nome e la descrizione della competenza usando il formato YAML.", + "supported-formats": "Formati supportati", + "max-file-size": "Dimensione massima del file", + "upload": "Carica", + "invalid-file-type": "Tipo di file non valido. Carica un file .skill, .md, .txt o .json.", + "file-too-large": "Il file è troppo grande. La dimensione massima è 1MB.", + "file-read-error": "Impossibile leggere il file. Riprova.", + "reupload-file": "Carica di nuovo un file", + "upload-error-invalid-format": "Il file deve essere un .zip o un pacchetto skill (.skill o .md).", + "upload-error-invalid-yaml": "SKILL.md deve definire name e description in formato YAML.", + "skill-added-success": "Competenza aggiunta con successo!", + "skill-add-error": "Impossibile aggiungere la competenza. Riprova.", + "custom-skill": "Competenza personalizzata", + "delete-skill": "Elimina competenza", + "delete-skill-confirmation": "Sei sicuro di voler eliminare \"{{name}}\"? Questa azione non può essere annullata.", + "skill-deleted-success": "Competenza eliminata con successo!" +} diff --git a/src/i18n/locales/it/index.ts b/src/i18n/locales/it/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/it/index.ts +++ b/src/i18n/locales/it/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/it/layout.json b/src/i18n/locales/it/layout.json index 1b5b365d0..75af7991e 100644 --- a/src/i18n/locales/it/layout.json +++ b/src/i18n/locales/it/layout.json @@ -31,8 +31,10 @@ "installation-failed": "Installazione fallita", "projects": "Progetti", "mcp-tools": "MCP e Strumenti", + "connectors": "Connettori", "browser": "Navigatore", "settings": "Impostazioni", + "general": "Generale", "workers": "Lavoratori", "triggers": "Trigger", "new-project": "Nuovo Progetto", @@ -166,5 +168,6 @@ "days-ago": "giorni fa", "delete-project": "Elimina Progetto", "delete-project-confirmation": "Sei sicuro di voler eliminare questo progetto e tutte le sue attività? Questa azione non può essere annullata.", - "please-select-model": "Seleziona un modello in Impostazioni > Modelli per continuare." + "please-select-model": "Seleziona un modello in Impostazioni > Modelli per continuare.", + "capabilities": "Capacità" } diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json new file mode 100644 index 000000000..eef61ac5a --- /dev/null +++ b/src/i18n/locales/ja/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "スキル", + "memory": "メモリ", + "preview": "プレビュー", + "skills-description": "カスタムスキルを追加してエージェントの能力を拡張します。", + "memory-description": "エージェントのメモリとナレッジベースを管理します。", + "memory-coming-soon-description": "メモリ機能により、エージェントがセッション間で重要な情報を記憶できるようになります。", + "learn-more": "詳細を見る", + "search-skills": "スキルを検索...", + "search-tooltip": "検索", + "clear-search-tooltip": "検索をクリア", + "add": "追加", + "your-skills": "あなたのスキル", + "example-skills": "サンプルスキル", + "no-skills-found": "一致するスキルが見つかりません。", + "no-your-skills": "まだスキルを追加していません。", + "no-example-skills": "利用可能なサンプルスキルがありません。", + "add-your-first-skill": "最初のスキルを追加", + "added": "追加日", + "global": "グローバル", + "partial-available": "部分的に利用可能(複数選択)", + "select-agents": "エージェントを選択", + "select-scope": "スコープを選択", + "skill-scope": "スキル範囲", + "selected": "選択済み", + "agents": "エージェント", + "no-agents-available": "利用可能なエージェントがありません", + "try-in-chat": "チャットで試す", + "add-skill": "スキルを追加", + "drag-and-drop": "ファイルをここにドラッグ&ドロップ", + "or-click-to-browse": "またはクリックして参照", + "file-requirements": "ファイル要件:", + "file-requirements-detail-1": "アップロードする .zip またはスキルパッケージには、ルートディレクトリに SKILL.md ファイルが含まれている必要があります。", + "file-requirements-detail-2": "SKILL.md ファイルは YAML 形式でスキル名と説明を定義する必要があります。", + "supported-formats": "サポート形式", + "max-file-size": "最大ファイルサイズ", + "upload": "アップロード", + "invalid-file-type": "無効なファイル形式です。.skill、.md、.txt、または .json ファイルをアップロードしてください。", + "file-too-large": "ファイルが大きすぎます。最大サイズは 1MB です。", + "file-read-error": "ファイルの読み込みに失敗しました。もう一度お試しください。", + "reupload-file": "ファイルを再アップロード", + "upload-error-invalid-format": "ファイルは .zip またはスキルパッケージ(.skill または .md)である必要があります。", + "upload-error-invalid-yaml": "SKILL.md では YAML 形式で name と description を定義する必要があります。", + "skill-added-success": "スキルが正常に追加されました!", + "skill-add-error": "スキルの追加に失敗しました。もう一度お試しください。", + "custom-skill": "カスタムスキル", + "delete-skill": "スキルを削除", + "delete-skill-confirmation": "\"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。", + "skill-deleted-success": "スキルが正常に削除されました!" +} diff --git a/src/i18n/locales/ja/index.ts b/src/i18n/locales/ja/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/ja/index.ts +++ b/src/i18n/locales/ja/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ja/layout.json b/src/i18n/locales/ja/layout.json index 6c0e9e45c..b033a43b7 100644 --- a/src/i18n/locales/ja/layout.json +++ b/src/i18n/locales/ja/layout.json @@ -31,8 +31,10 @@ "installation-failed": "インストールに失敗しました", "projects": "プロジェクト", "mcp-tools": "MCP & ツール", + "connectors": "コネクタ", "browser": "ブラウザ", "settings": "設定", + "general": "一般", "workers": "ワーカー", "triggers": "トリガー", "new-project": "新規プロジェクト", @@ -166,5 +168,6 @@ "days-ago": "日前", "delete-project": "プロジェクトを削除", "delete-project-confirmation": "このプロジェクトとそのすべてのタスクを削除してもよろしいですか?この操作は元に戻せません。", - "please-select-model": "続行するには、設定 > モデルでモデルを選択してください。" + "please-select-model": "続行するには、設定 > モデルでモデルを選択してください。", + "capabilities": "機能" } diff --git a/src/i18n/locales/ko/agents.json b/src/i18n/locales/ko/agents.json new file mode 100644 index 000000000..5649fb6d1 --- /dev/null +++ b/src/i18n/locales/ko/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "스킬", + "memory": "메모리", + "preview": "미리보기", + "skills-description": "사용자 정의 스킬을 추가하여 에이전트 기능을 확장하세요.", + "memory-description": "에이전트의 메모리와 지식 베이스를 관리하세요.", + "memory-coming-soon-description": "메모리 기능을 통해 에이전트가 세션 간에 중요한 정보를 기억할 수 있습니다.", + "learn-more": "자세히 알아보기", + "search-skills": "스킬 검색...", + "search-tooltip": "검색", + "clear-search-tooltip": "검색 지우기", + "add": "추가", + "your-skills": "내 스킬", + "example-skills": "예제 스킬", + "no-skills-found": "일치하는 스킬을 찾을 수 없습니다.", + "no-your-skills": "아직 스킬을 추가하지 않았습니다.", + "no-example-skills": "사용 가능한 예제 스킬이 없습니다.", + "add-your-first-skill": "첫 번째 스킬 추가", + "added": "추가됨", + "global": "전역", + "partial-available": "부분 사용 가능 (다중 선택)", + "select-agents": "에이전트 선택", + "select-scope": "범위 선택", + "skill-scope": "스킬 범위", + "selected": "선택됨", + "agents": "에이전트", + "no-agents-available": "사용 가능한 에이전트가 없습니다", + "try-in-chat": "채팅에서 사용해보기", + "add-skill": "스킬 추가", + "drag-and-drop": "여기에 파일을 드래그 앤 드롭하세요", + "or-click-to-browse": "또는 클릭하여 찾아보기", + "file-requirements": "파일 요구 사항:", + "file-requirements-detail-1": "업로드된 .zip 또는 스킬 패키지에는 루트 디렉토리에 SKILL.md 파일이 포함되어야 합니다.", + "file-requirements-detail-2": "SKILL.md 파일은 YAML 형식으로 스킬 이름과 설명을 정의해야 합니다.", + "supported-formats": "지원 형식", + "max-file-size": "최대 파일 크기", + "upload": "업로드", + "invalid-file-type": "잘못된 파일 유형입니다. .skill, .md, .txt 또는 .json 파일을 업로드해 주세요.", + "file-too-large": "파일이 너무 큽니다. 최대 크기는 1MB입니다.", + "file-read-error": "파일 읽기에 실패했습니다. 다시 시도해 주세요.", + "reupload-file": "파일 다시 업로드", + "upload-error-invalid-format": "파일은 .zip 또는 스킬 패키지(.skill 또는 .md)여야 합니다.", + "upload-error-invalid-yaml": "SKILL.md에는 YAML 형식으로 name과 description이 정의되어 있어야 합니다.", + "skill-added-success": "스킬이 성공적으로 추가되었습니다!", + "skill-add-error": "스킬 추가에 실패했습니다. 다시 시도해 주세요.", + "custom-skill": "사용자 정의 스킬", + "delete-skill": "스킬 삭제", + "delete-skill-confirmation": "\"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "skill-deleted-success": "스킬이 성공적으로 삭제되었습니다!" +} diff --git a/src/i18n/locales/ko/index.ts b/src/i18n/locales/ko/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/ko/index.ts +++ b/src/i18n/locales/ko/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ko/layout.json b/src/i18n/locales/ko/layout.json index d6678e942..40809222c 100644 --- a/src/i18n/locales/ko/layout.json +++ b/src/i18n/locales/ko/layout.json @@ -31,8 +31,10 @@ "installation-failed": "설치 실패", "projects": "프로젝트", "mcp-tools": "MCP 및 도구", + "connectors": "커넥터", "browser": "브라우저", "settings": "설정", + "general": "일반", "workers": "작업자", "triggers": "트리거", "new-project": "새 프로젝트", @@ -166,5 +168,6 @@ "days-ago": "일 전", "delete-project": "프로젝트 삭제", "delete-project-confirmation": "이 프로젝트와 모든 작업을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", - "please-select-model": "계속하려면 설정 > 모델에서 모델을 선택하세요." + "please-select-model": "계속하려면 설정 > 모델에서 모델을 선택하세요.", + "capabilities": "기능" } diff --git a/src/i18n/locales/ru/agents.json b/src/i18n/locales/ru/agents.json new file mode 100644 index 000000000..ef7c027f3 --- /dev/null +++ b/src/i18n/locales/ru/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "Навыки", + "memory": "Память", + "preview": "Предпросмотр", + "skills-description": "Добавьте пользовательские навыки для расширения возможностей вашего агента.", + "memory-description": "Управляйте памятью и базой знаний вашего агента.", + "memory-coming-soon-description": "Функции памяти позволят вашим агентам запоминать важную информацию между сеансами.", + "learn-more": "Узнать больше", + "search-skills": "Поиск навыков...", + "search-tooltip": "Поиск", + "clear-search-tooltip": "Очистить поиск", + "add": "Добавить", + "your-skills": "Ваши навыки", + "example-skills": "Примеры навыков", + "no-skills-found": "Навыки не найдены.", + "no-your-skills": "Вы ещё не добавили навыки.", + "no-example-skills": "Нет доступных примеров навыков.", + "add-your-first-skill": "Добавьте свой первый навык", + "added": "Добавлено", + "global": "Глобальный", + "partial-available": "Частично доступно (множественный выбор)", + "select-agents": "Выберите агентов", + "select-scope": "Выберите область", + "skill-scope": "Область навыка", + "selected": "выбрано", + "agents": "Агенты", + "no-agents-available": "Нет доступных агентов", + "try-in-chat": "Попробовать в чате", + "add-skill": "Добавить навык", + "drag-and-drop": "Перетащите файл сюда", + "or-click-to-browse": "или нажмите для выбора", + "file-requirements": "Требования к файлу:", + "file-requirements-detail-1": "Загруженный .zip или пакет навыков должен содержать файл SKILL.md в корневом каталоге.", + "file-requirements-detail-2": "Файл SKILL.md должен определять название и описание навыка в формате YAML.", + "supported-formats": "Поддерживаемые форматы", + "max-file-size": "Максимальный размер файла", + "upload": "Загрузить", + "invalid-file-type": "Неверный тип файла. Пожалуйста, загрузите файл .skill, .md, .txt или .json.", + "file-too-large": "Файл слишком большой. Максимальный размер 1MB.", + "file-read-error": "Не удалось прочитать файл. Пожалуйста, попробуйте снова.", + "reupload-file": "Загрузить файл снова", + "upload-error-invalid-format": "Файл должен быть .zip или пакетом навыка (.skill или .md).", + "upload-error-invalid-yaml": "В SKILL.md должны быть указаны name и description в формате YAML.", + "skill-added-success": "Навык успешно добавлен!", + "skill-add-error": "Не удалось добавить навык. Пожалуйста, попробуйте снова.", + "custom-skill": "Пользовательский навык", + "delete-skill": "Удалить навык", + "delete-skill-confirmation": "Вы уверены, что хотите удалить \"{{name}}\"? Это действие нельзя отменить.", + "skill-deleted-success": "Навык успешно удалён!" +} diff --git a/src/i18n/locales/ru/index.ts b/src/i18n/locales/ru/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/ru/index.ts +++ b/src/i18n/locales/ru/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index bc93cfc18..60241f8b2 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -31,8 +31,10 @@ "installation-failed": "Установка не удалась", "projects": "Проекты", "mcp-tools": "MCP и Инструменты", + "connectors": "Коннекторы", "browser": "Браузер", "settings": "Настройки", + "general": "Общие", "workers": "Работники", "triggers": "Триггеры", "new-project": "Новый проект", @@ -166,5 +168,6 @@ "days-ago": "дней назад", "delete-project": "Удалить проект", "delete-project-confirmation": "Вы уверены, что хотите удалить этот проект и все его задачи? Это действие нельзя отменить.", - "please-select-model": "Пожалуйста, выберите модель в Настройки > Модели, чтобы продолжить." + "please-select-model": "Пожалуйста, выберите модель в Настройки > Модели, чтобы продолжить.", + "capabilities": "Возможности" } diff --git a/src/i18n/locales/zh-Hans/agents.json b/src/i18n/locales/zh-Hans/agents.json new file mode 100644 index 000000000..adedc1988 --- /dev/null +++ b/src/i18n/locales/zh-Hans/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "技能", + "memory": "记忆", + "preview": "预览", + "skills-description": "添加自定义技能以扩展您的智能体能力。", + "memory-description": "管理您的智能体的记忆和知识库。", + "memory-coming-soon-description": "记忆功能将允许您的智能体在会话之间记住重要信息。", + "learn-more": "了解更多", + "search-skills": "搜索技能...", + "search-tooltip": "搜索", + "clear-search-tooltip": "清除搜索", + "add": "添加", + "your-skills": "您的技能", + "example-skills": "示例技能", + "no-skills-found": "未找到匹配的技能。", + "no-your-skills": "您还没有添加任何技能。", + "no-example-skills": "没有可用的示例技能。", + "add-your-first-skill": "添加您的第一个技能", + "added": "添加于", + "global": "全局", + "partial-available": "部分可用(多选)", + "select-agents": "选择智能体", + "select-scope": "选择范围", + "skill-scope": "技能范围", + "selected": "已选择", + "agents": "智能体", + "no-agents-available": "没有可用的智能体", + "try-in-chat": "在对话中尝试", + "add-skill": "添加技能", + "drag-and-drop": "拖放文件到此处", + "or-click-to-browse": "或点击浏览", + "file-requirements": "文件要求:", + "file-requirements-detail-1": "上传的 .zip 或技能包必须在根目录中包含一个 SKILL.md 文件。", + "file-requirements-detail-2": "SKILL.md 文件必须使用 YAML 格式定义技能名称和描述。", + "supported-formats": "支持的格式", + "max-file-size": "最大文件大小", + "upload": "上传", + "invalid-file-type": "无效的文件类型。请上传 .skill、.md、.txt 或 .json 文件。", + "file-too-large": "文件太大。最大大小为 1MB。", + "file-read-error": "读取文件失败。请重试。", + "reupload-file": "重新上传文件", + "upload-error-invalid-format": "文件必须为 .zip 或技能包(.skill 或 .md)。", + "upload-error-invalid-yaml": "SKILL.md 必须使用 YAML 格式定义 name 和 description。", + "skill-added-success": "技能添加成功!", + "skill-add-error": "添加技能失败。请重试。", + "custom-skill": "自定义技能", + "delete-skill": "删除技能", + "delete-skill-confirmation": "您确定要删除 \"{{name}}\" 吗?此操作无法撤销。", + "skill-deleted-success": "技能删除成功!" +} diff --git a/src/i18n/locales/zh-Hans/index.ts b/src/i18n/locales/zh-Hans/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/zh-Hans/index.ts +++ b/src/i18n/locales/zh-Hans/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hans/layout.json b/src/i18n/locales/zh-Hans/layout.json index 4a28cc55e..933e99d7b 100644 --- a/src/i18n/locales/zh-Hans/layout.json +++ b/src/i18n/locales/zh-Hans/layout.json @@ -34,8 +34,10 @@ "backend-startup-failed": "后端启动失败", "projects": "项目", "mcp-tools": "MCP & 工具", + "connectors": "连接器", "browser": "浏览器", "settings": "设置", + "general": "通用", "workers": "工作器", "triggers": "触发器", "new-project": "新项目", @@ -168,5 +170,6 @@ "days-ago": "天前", "delete-project": "删除项目", "delete-project-confirmation": "您确定要删除此项目及其所有任务吗?此操作无法撤销。", - "please-select-model": "请在设置 > 模型中选择一个模型以继续。" + "please-select-model": "请在设置 > 模型中选择一个模型以继续。", + "capabilities": "能力" } diff --git a/src/i18n/locales/zh-Hant/agents.json b/src/i18n/locales/zh-Hant/agents.json new file mode 100644 index 000000000..2884243db --- /dev/null +++ b/src/i18n/locales/zh-Hant/agents.json @@ -0,0 +1,50 @@ +{ + "skills": "技能", + "memory": "記憶", + "preview": "預覽", + "skills-description": "新增自訂技能以擴展您的智能體能力。", + "memory-description": "管理您的智能體的記憶和知識庫。", + "memory-coming-soon-description": "記憶功能將允許您的智能體在會話之間記住重要資訊。", + "learn-more": "了解更多", + "search-skills": "搜尋技能...", + "search-tooltip": "搜尋", + "clear-search-tooltip": "清除搜尋", + "add": "新增", + "your-skills": "您的技能", + "example-skills": "範例技能", + "no-skills-found": "未找到匹配的技能。", + "no-your-skills": "您還沒有新增任何技能。", + "no-example-skills": "沒有可用的範例技能。", + "add-your-first-skill": "新增您的第一個技能", + "added": "新增於", + "global": "全域", + "partial-available": "部分可用(多選)", + "select-agents": "選擇智能體", + "select-scope": "選擇範圍", + "skill-scope": "技能範圍", + "selected": "已選擇", + "agents": "智能體", + "no-agents-available": "沒有可用的智能體", + "try-in-chat": "在對話中嘗試", + "add-skill": "新增技能", + "drag-and-drop": "拖放檔案到此處", + "or-click-to-browse": "或點擊瀏覽", + "file-requirements": "檔案要求:", + "file-requirements-detail-1": "上傳的 .zip 或技能包必須在根目錄中包含一個 SKILL.md 檔案。", + "file-requirements-detail-2": "SKILL.md 檔案必須使用 YAML 格式定義技能名稱和描述。", + "supported-formats": "支援的格式", + "max-file-size": "最大檔案大小", + "upload": "上傳", + "invalid-file-type": "無效的檔案類型。請上傳 .skill、.md、.txt 或 .json 檔案。", + "file-too-large": "檔案太大。最大大小為 1MB。", + "file-read-error": "讀取檔案失敗。請重試。", + "reupload-file": "重新上傳檔案", + "upload-error-invalid-format": "檔案必須為 .zip 或技能套件(.skill 或 .md)。", + "upload-error-invalid-yaml": "SKILL.md 必須使用 YAML 格式定義 name 與 description。", + "skill-added-success": "技能新增成功!", + "skill-add-error": "新增技能失敗。請重試。", + "custom-skill": "自訂技能", + "delete-skill": "刪除技能", + "delete-skill-confirmation": "您確定要刪除 \"{{name}}\" 嗎?此操作無法撤銷。", + "skill-deleted-success": "技能刪除成功!" +} diff --git a/src/i18n/locales/zh-Hant/index.ts b/src/i18n/locales/zh-Hant/index.ts index 47ed589ff..dc2a149a6 100644 --- a/src/i18n/locales/zh-Hant/index.ts +++ b/src/i18n/locales/zh-Hant/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -19,6 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hant/layout.json b/src/i18n/locales/zh-Hant/layout.json index 1a33236cd..1b7a4e966 100644 --- a/src/i18n/locales/zh-Hant/layout.json +++ b/src/i18n/locales/zh-Hant/layout.json @@ -33,8 +33,10 @@ "installation-failed": "安装失败", "projects": "專案", "mcp-tools": "MCP & 工具", + "connectors": "連接器", "browser": "瀏覽器", "settings": "設定", + "general": "一般", "workers": "工作器", "triggers": "觸發器", "new-project": "新專案", @@ -168,5 +170,6 @@ "days-ago": "天前", "delete-project": "刪除專案", "delete-project-confirmation": "您確定要刪除此專案及其所有任務嗎?此操作無法撤銷。", - "please-select-model": "請在設定 > 模型中選擇一個模型以繼續。" + "please-select-model": "請在設定 > 模型中選擇一個模型以繼續。", + "capabilities": "能力" } diff --git a/src/lib/skillToolkit.ts b/src/lib/skillToolkit.ts new file mode 100644 index 000000000..3a84bd945 --- /dev/null +++ b/src/lib/skillToolkit.ts @@ -0,0 +1,141 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Skill toolkit utilities aligned with CAMEL's skill_toolkit: + * https://github.com/camel-ai/camel/blob/master/camel/toolkits/skill_toolkit.py + * + * Skills are stored as SKILL.md files with YAML frontmatter (name, description) + * and a markdown body. Discovery order: repo > user > system (CAMEL); + * in Eigent we use user scope at ~/.eigent/.camel/skills (one folder per skill). + */ + +export interface SkillMeta { + name: string; + description: string; + body: string; +} + +export interface ScannedSkill { + name: string; + description: string; + path: string; + scope: 'repo' | 'user' | 'system'; + /** Folder name under skills dir (e.g. "my-skill") */ + skillDirName: string; +} + +const FRONTMATTER_DELIM = '---'; + +/** + * Split YAML frontmatter from the body of a SKILL.md file. + * Expects content to start with "---", then YAML, then "---", then body. + */ +export function splitFrontmatter(contents: string): { + frontmatter: string | null; + body: string; +} { + // Strip BOM and leading whitespace/newlines so the first `---` is detected + const cleaned = contents.replace(/^\uFEFF/, '').trimStart(); + const lines = cleaned.split('\n'); + if (!lines.length || lines[0].trim() !== FRONTMATTER_DELIM) { + return { frontmatter: null, body: cleaned }; + } + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === FRONTMATTER_DELIM) { + const frontmatter = lines.slice(1, i).join('\n'); + const body = lines.slice(i + 1).join('\n'); + return { frontmatter, body }; + } + } + return { frontmatter: null, body: cleaned }; +} + +/** Simple YAML-like parse for "name:" and "description:" (first-level keys only). */ +function parseSimpleYaml(text: string): Record { + const out: Record = {}; + const lines = text.split('\n'); + for (const line of lines) { + const match = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/); + if (match) { + const value = match[2].trim(); + // Lowercase key so `Name:` / `name:` / `NAME:` all work + out[match[1].toLowerCase()] = value + .replace(/^['"]|['"]$/g, '') + .replace(/\\"/g, '"') + .trim(); + } + } + return out; +} + +/** + * Parse a SKILL.md file content and extract name, description, and body. + * Compatible with CAMEL's _parse_skill format. + */ +export function parseSkillMd(contents: string): SkillMeta | null { + const { frontmatter, body } = splitFrontmatter(contents); + if (!frontmatter) return null; + const data = parseSimpleYaml(frontmatter); + const name = data.name; + const description = data.description; + if (typeof name !== 'string' || typeof description !== 'string') return null; + return { + name: name.trim(), + description: description.trim(), + body: body.trim(), + }; +} + +/** + * Build SKILL.md content from name, description, and body (CAMEL-compatible). + */ +export function buildSkillMd( + name: string, + description: string, + body: string +): string { + const escapedDescription = description + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + const front = [ + FRONTMATTER_DELIM, + `name: ${name}`, + `description: "${escapedDescription}"`, + FRONTMATTER_DELIM, + '', + body, + ].join('\n'); + return front; +} + +/** + * Sanitize a skill name into a safe folder name (no path separators, no dots at start). + */ +export function skillNameToDirName(name: string): string { + // Keep original casing so that folder name matches skill name as closely + // as possible, only stripping/normalizing unsafe characters. + const cleaned = name + .replace(/[\\/*?:"<>|\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return cleaned || 'skill'; +} + +/** Check if running in Electron with skills API available */ +export function hasSkillsFsApi(): boolean { + return ( + typeof window !== 'undefined' && !!(window as any).electronAPI?.skillsScan + ); +} diff --git a/src/pages/Agents/Memory.tsx b/src/pages/Agents/Memory.tsx new file mode 100644 index 000000000..2647c67c3 --- /dev/null +++ b/src/pages/Agents/Memory.tsx @@ -0,0 +1,46 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { Brain } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export default function Memory() { + const { t } = useTranslation(); + + return ( +
+ {/* Header Section */} +
+
+ {t('agents.memory')} +
+
+ + {/* Content Section */} +
+
+
+ +
+

+ {t('layout.coming-soon')} +

+

+ {t('agents.memory-coming-soon-description')} +

+
+
+
+ ); +} diff --git a/src/pages/Setting/Models.tsx b/src/pages/Agents/Models.tsx similarity index 100% rename from src/pages/Setting/Models.tsx rename to src/pages/Agents/Models.tsx diff --git a/src/pages/Agents/Skills.tsx b/src/pages/Agents/Skills.tsx new file mode 100644 index 000000000..1dd8b659d --- /dev/null +++ b/src/pages/Agents/Skills.tsx @@ -0,0 +1,193 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import SearchInput from '@/components/SearchInput'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useSkillsStore, type Skill } from '@/store/skillsStore'; +import { Plus } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import SkillDeleteDialog from './components/SkillDeleteDialog'; +import SkillListItem from './components/SkillListItem'; +import SkillUploadDialog from './components/SkillUploadDialog'; + +export default function Skills() { + const { t } = useTranslation(); + const { skills, syncFromDisk } = useSkillsStore(); + const [searchQuery, setSearchQuery] = useState(''); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [skillToDelete, setSkillToDelete] = useState(null); + + // On first mount, sync skills from local SKILL.md files + useEffect(() => { + // No-op on web; in Electron this will scan ~/.eigent/skills + syncFromDisk(); + }, [syncFromDisk]); + + const yourSkills = useMemo(() => { + return skills + .filter((skill) => !skill.isExample) + .filter( + (skill) => + skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || + skill.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [skills, searchQuery]); + + const exampleSkills = useMemo(() => { + return skills + .filter((skill) => skill.isExample) + .filter( + (skill) => + skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || + skill.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [skills, searchQuery]); + + const handleDeleteClick = (skill: Skill) => { + setSkillToDelete(skill); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = () => { + setDeleteDialogOpen(false); + setSkillToDelete(null); + }; + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setSkillToDelete(null); + }; + + return ( +
+ {/* Header Section */} +
+
+ {t('agents.skills')} +
+
+ + {/* Content Section */} +
+
+ +
+ + + {t('agents.your-skills')} + + {/* TODO: Add example skills back in */} + {/* + {t('agents.example-skills')} + */} + +
+ setSearchQuery(e.target.value)} + placeholder={t('agents.search-skills')} + /> + +
+
+ + {yourSkills.length === 0 ? ( + setUploadDialogOpen(true) : undefined + } + /> + ) : ( +
+ {yourSkills.map((skill) => ( + handleDeleteClick(skill)} + /> + ))} +
+ )} +
+ + {exampleSkills.length === 0 ? ( + + ) : ( +
+ {exampleSkills.map((skill) => ( + + ))} +
+ )} +
+
+
+
+ + {/* Upload Dialog */} + setUploadDialogOpen(false)} + /> + + {/* Delete Dialog */} + +
+ ); +} diff --git a/src/pages/Agents/components/SkillDeleteDialog.tsx b/src/pages/Agents/components/SkillDeleteDialog.tsx new file mode 100644 index 000000000..1aec0f88d --- /dev/null +++ b/src/pages/Agents/components/SkillDeleteDialog.tsx @@ -0,0 +1,58 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import ConfirmModal from '@/components/ui/alertDialog'; +import { useSkillsStore, type Skill } from '@/store/skillsStore'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +interface SkillDeleteDialogProps { + open: boolean; + skill: Skill | null; + onConfirm: () => void; + onCancel: () => void; +} + +export default function SkillDeleteDialog({ + open, + skill, + onConfirm, + onCancel, +}: SkillDeleteDialogProps) { + const { t } = useTranslation(); + const { deleteSkill } = useSkillsStore(); + + const handleDelete = () => { + if (skill) { + deleteSkill(skill.id); + toast.success(t('agents.skill-deleted-success')); + } + onConfirm(); + }; + + return ( + + ); +} diff --git a/src/pages/Agents/components/SkillListItem.tsx b/src/pages/Agents/components/SkillListItem.tsx new file mode 100644 index 000000000..1b275e550 --- /dev/null +++ b/src/pages/Agents/components/SkillListItem.tsx @@ -0,0 +1,297 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { + getWorkflowAgentDisplay, + WORKFLOW_AGENT_LIST, +} from '@/components/WorkFlow/agents'; +import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useWorkerList } from '@/store/authStore'; +import { useSkillsStore, type Skill } from '@/store/skillsStore'; +import { + Bot, + Check, + ChevronRight, + Ellipsis, + MessageSquare, + Plus, + Trash2, + Users, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +interface SkillListItemDefaultProps { + variant?: 'default'; + skill: Skill; + onDelete?: () => void; + message?: never; + addButtonText?: never; + onAddClick?: never; +} + +interface SkillListItemPlaceholderProps { + variant: 'placeholder'; + skill?: never; + onDelete?: never; + message: string; + addButtonText?: string; + onAddClick?: () => void; +} + +type SkillListItemProps = + | SkillListItemDefaultProps + | SkillListItemPlaceholderProps; + +export default function SkillListItem(props: SkillListItemProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { updateSkill } = useSkillsStore(); + const { projectStore } = useChatStoreAdapter(); + const workerList = useWorkerList(); + const [scopeOpen, setScopeOpen] = useState(false); + + const allAgents = useMemo(() => { + const workflowNames = WORKFLOW_AGENT_LIST.map((a) => a.name); + const workerNames = workerList.map((w) => w.name); + const combined = [...workflowNames]; + workerNames.forEach((name) => { + if (!combined.includes(name)) { + combined.push(name); + } + }); + return combined; + }, [workerList]); + + if (props.variant === 'placeholder') { + const isClickable = props.onAddClick != null; + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + props.onAddClick?.(); + } + } + : undefined + } + aria-label={isClickable ? props.addButtonText : undefined} + > +

{props.message}

+ {isClickable && } +
+ ); + } + + const { skill, onDelete } = props; + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffDays === 0) { + return t('layout.today'); + } else if (diffDays === 1) { + return t('layout.yesterday'); + } else if (diffDays < 30) { + return `${diffDays} ${t('layout.days-ago')}`; + } else { + return date.toLocaleDateString(); + } + }; + + const handleScopeChange = (scope: { + isGlobal: boolean; + selectedAgents: string[]; + }) => { + updateSkill(skill.id, { scope }); + }; + + const isAllAgentsSelected = skill.scope.isGlobal; + + const handleToggleAllAgents = () => { + if (isAllAgentsSelected) { + // When user unselects "All agents", clear all individual selections + handleScopeChange({ + isGlobal: false, + selectedAgents: [], + }); + } else { + // When user selects "All agents", use empty array to indicate ALL agents (including future ones) + handleScopeChange({ + isGlobal: true, + selectedAgents: [], // Empty array = all agents, including future agents + }); + } + }; + + const handleToggleAgent = (agentName: string) => { + if (isAllAgentsSelected) { + const newSelectedAgents = allAgents.filter((a) => a !== agentName); + handleScopeChange({ + isGlobal: false, + selectedAgents: newSelectedAgents, + }); + return; + } + + const isSelected = skill.scope.selectedAgents.includes(agentName); + const newSelectedAgents = isSelected + ? skill.scope.selectedAgents.filter((a) => a !== agentName) + : [...skill.scope.selectedAgents, agentName]; + handleScopeChange({ + isGlobal: false, + selectedAgents: newSelectedAgents, + }); + }; + + const handleTryInChat = () => { + projectStore?.createProject('new project'); + const prompt = `I just added the {{${skill.name}}} skill for Eigent, can you make something amazing with this skill?`; + navigate(`/?skill_prompt=${encodeURIComponent(prompt)}`); + }; + + return ( +
+ {/* Row 1: Name / Actions */} +
+
+ + {skill.name} + +
+ +
+ + {t('agents.added')} {formatDate(skill.addedAt)} + + + + + + + + + {t('agents.try-in-chat')} + + {!skill.isExample && onDelete && ( + + + {t('layout.delete')} + + )} + + +
+
+ + {/* Row 2: Description - 5 lines max, hover shows full */} + +
+

+ {skill.description} +

+
+
+ + {/* Row 3: Added time / Skill scope */} +
+ + + {scopeOpen && ( +
+ {/* All agents as first tab; then each agent toggle */} + + + {allAgents.map((agentName) => { + const isSelected = + isAllAgentsSelected || + skill.scope.selectedAgents.includes(agentName); + const display = getWorkflowAgentDisplay(agentName); + const icon = display?.icon ?? ( + + ); + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/pages/Agents/components/SkillUploadDialog.tsx b/src/pages/Agents/components/SkillUploadDialog.tsx new file mode 100644 index 000000000..3a50e0a15 --- /dev/null +++ b/src/pages/Agents/components/SkillUploadDialog.tsx @@ -0,0 +1,371 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogContentSection, + DialogHeader, +} from '@/components/ui/dialog'; +import { parseSkillMd } from '@/lib/skillToolkit'; +import { useSkillsStore } from '@/store/skillsStore'; +import { AlertCircle, File, Upload, X } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +interface SkillUploadDialogProps { + open: boolean; + onClose: () => void; +} + +export default function SkillUploadDialog({ + open, + onClose, +}: SkillUploadDialogProps) { + const { t } = useTranslation(); + const { addSkill, syncFromDisk } = useSkillsStore(); + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(''); + const [_isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const [isZip, setIsZip] = useState(false); + const [uploadError, setUploadError] = useState< + 'invalid_format' | 'invalid_yaml' | null + >(null); + + const handleClose = useCallback(() => { + setSelectedFile(null); + setFileContent(''); + setIsDragging(false); + setIsZip(false); + setUploadError(null); + onClose(); + }, [onClose]); + + const handleUpload = useCallback( + async ( + fileArg?: File, + options?: { isZipOverride?: boolean; contentOverride?: string } + ) => { + const fileToUse = fileArg ?? selectedFile; + if (!fileToUse) return; + + const isZipToUse = options?.isZipOverride ?? isZip; + const fileContentToUse = options?.contentOverride ?? fileContent; + + setIsUploading(true); + try { + // Zip import: read file in renderer and send buffer to main (no path in sandbox) + if (isZipToUse) { + if (!(window as any).electronAPI?.skillImportZip) { + toast.error(t('agents.skill-add-error')); + return; + } + let buffer: ArrayBuffer; + try { + buffer = await fileToUse.arrayBuffer(); + } catch { + toast.error(t('agents.file-read-error')); + return; + } + const result = await (window as any).electronAPI.skillImportZip( + buffer + ); + if (!result?.success) { + toast.error(result?.error || t('agents.skill-add-error')); + return; + } + await syncFromDisk(); + toast.success(t('agents.skill-added-success')); + handleClose(); + return; + } + + if (!fileContentToUse) return; + + const fileName = fileToUse.name.replace(/\.[^/.]+$/, ''); + + // Prefer SKILL.md frontmatter (name + description) at upload time + const meta = parseSkillMd(fileContentToUse); + let name = meta?.name ?? fileName; + let description = meta?.description ?? ''; + + // Fallback: no frontmatter — use first heading and first paragraph + if (!meta && fileContentToUse.startsWith('#')) { + const lines = fileContentToUse.split('\n'); + const headingMatch = lines[0].match(/^#\s+(.+)/); + if (headingMatch) name = headingMatch[1]; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (line && !line.startsWith('#')) { + description = line; + break; + } + } + } + + addSkill({ + name, + description: description || t('agents.custom-skill'), + filePath: fileToUse.name, + fileContent: fileContentToUse, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: true, + }); + + toast.success(t('agents.skill-added-success')); + handleClose(); + } catch (_error) { + toast.error(t('agents.skill-add-error')); + } finally { + setIsUploading(false); + } + }, + [addSkill, fileContent, handleClose, isZip, selectedFile, syncFromDisk, t] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const processFile = useCallback( + async (file: File) => { + // Only .zip or skill package (.skill, .md) are valid + const skillPackageExtensions = ['.zip', '.skill', '.md']; + const extension = file.name + .substring(file.name.lastIndexOf('.')) + .toLowerCase(); + + if (!skillPackageExtensions.includes(extension)) { + setSelectedFile(file); + setUploadError('invalid_format'); + return; + } + + // Validate file size (max 5MB to allow small zip bundles) + if (file.size > 5 * 1024 * 1024) { + toast.error(t('agents.file-too-large')); + return; + } + + try { + setUploadError(null); + setSelectedFile(file); + if (extension === '.zip') { + setIsZip(true); + setFileContent(''); + await handleUpload(file, { + isZipOverride: true, + contentOverride: '', + }); + } else { + const content = await file.text(); + setIsZip(false); + setFileContent(content); + // .skill / .md must have YAML frontmatter (name + description) + const meta = parseSkillMd(content); + if (!meta) { + setUploadError('invalid_yaml'); + return; + } + await handleUpload(file, { + isZipOverride: false, + contentOverride: content, + }); + } + } catch (_error) { + toast.error(t('agents.file-read-error')); + } + }, + [handleUpload, t] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = e.dataTransfer.files; + if (files.length > 0) { + processFile(files[0]); + } + }, + [processFile] + ); + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFile(files[0]); + } + }; + + const handleRemoveFile = () => { + setSelectedFile(null); + setFileContent(''); + setUploadError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const errorMessage = + uploadError === 'invalid_format' + ? t('agents.upload-error-invalid-format') + : uploadError === 'invalid_yaml' + ? t('agents.upload-error-invalid-yaml') + : null; + + return ( + !isOpen && handleClose()}> + + + +
+ {/* Drop Zone */} +
fileInputRef.current?.click()} + > + + + {selectedFile ? ( +
+
+
+ +
+
+ + {selectedFile.name} + +
+ +
+ + + {uploadError + ? t('agents.reupload-file') + : `${(selectedFile.size / 1024).toFixed(1)} KB`} + +
+ ) : ( +
+
+ +
+
+ + {t('agents.drag-and-drop')} + + + {t('agents.or-click-to-browse')} + +
+
+ )} +
+ + {/* Error notice */} + {uploadError && errorMessage && ( +
+ + + {errorMessage} + +
+ )} + + {/* File Requirements */} +
+ + {t('agents.file-requirements')} + + + + {t('agents.file-requirements-detail-1')} + + + + {t('agents.file-requirements-detail-2')} + +
+
+
+
+
+ ); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx new file mode 100644 index 000000000..d8543f9df --- /dev/null +++ b/src/pages/Agents/index.tsx @@ -0,0 +1,78 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import VerticalNavigation, { + type VerticalNavItem, +} from '@/components/Navigation'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Memory from './Memory'; +import Models from './Models'; +import Skills from './Skills'; + +export default function Capabilities() { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState('skills'); + + const menuItems = [ + { + id: 'models', + name: t('setting.models'), + }, + { + id: 'skills', + name: t('agents.skills'), + }, + { + id: 'memory', + name: t('agents.memory'), + }, + ]; + + const handleTabChange = (tabId: string) => { + setActiveTab(tabId); + }; + + return ( +
+
+
+ ({ + value: menu.id, + label: ( + {menu.name} + ), + })) as VerticalNavItem[] + } + value={activeTab} + onValueChange={handleTabChange} + className="h-full min-h-0 w-full flex-1 gap-0" + listClassName="w-full h-full overflow-y-auto" + contentClassName="hidden" + /> +
+ +
+
+ {activeTab === 'models' && } + {activeTab === 'skills' && } + {activeTab === 'memory' && } +
+
+
+
+ ); +} diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 3551d840e..45b8b73a4 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { Bot } from '@/components/animate-ui/icons/bot'; import { Compass } from '@/components/animate-ui/icons/compass'; import { Hammer } from '@/components/animate-ui/icons/hammer'; import { Settings } from '@/components/animate-ui/icons/settings'; @@ -31,6 +32,7 @@ import { Plus } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import Agents from './Agents'; import Browser from './Dashboard/Browser'; import MCP from './Setting/MCP'; @@ -41,6 +43,7 @@ const VALID_TABS = [ 'settings', 'mcp_tools', 'browser', + 'agents', ] as const; type TabType = (typeof VALID_TABS)[number]; @@ -152,13 +155,21 @@ export default function Home() { > {t('layout.projects')} + } + > + {t('setting.agents')} + } > - {t('layout.mcp-tools')} + {t('layout.connectors')} } > - {t('layout.settings')} + {t('layout.general')}
@@ -188,6 +199,7 @@ export default function Home() { {activeTab === 'mcp_tools' && } {activeTab === 'browser' && } {activeTab === 'settings' && } + {activeTab === 'agents' && }
); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index c20f83036..7f070829c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -144,7 +144,7 @@ export default function Home() { // }); // chatStore.setSnapshots(chatStore.activeTaskId as string, list); }) - .catch((error) => { + .catch((error: unknown) => { console.error('capture webview error:', error); }); }); diff --git a/src/pages/Setting.tsx b/src/pages/Setting.tsx index 586058c6f..a2d113e64 100644 --- a/src/pages/Setting.tsx +++ b/src/pages/Setting.tsx @@ -19,10 +19,9 @@ import VerticalNavigation, { } from '@/components/Navigation'; import useAppVersion from '@/hooks/use-app-version'; import General from '@/pages/Setting/General'; -import Models from '@/pages/Setting/Models'; import Privacy from '@/pages/Setting/Privacy'; import { useAuthStore } from '@/store/authStore'; -import { Fingerprint, Settings, TagIcon, TextSelect } from 'lucide-react'; +import { Fingerprint, Settings, TagIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -48,12 +47,6 @@ export default function Setting() { icon: Fingerprint, path: '/setting/privacy', }, - { - id: 'models', - name: t('setting.models'), - icon: TextSelect, - path: '/setting/models', - }, ]; // Initialize tab from URL once, then manage locally without routing const getCurrentTab = () => { @@ -130,7 +123,6 @@ export default function Setting() {
{activeTab === 'general' && } {activeTab === 'privacy' && } - {activeTab === 'models' && }
diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts new file mode 100644 index 000000000..4485c89e4 --- /dev/null +++ b/src/store/skillsStore.ts @@ -0,0 +1,369 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + buildSkillMd, + hasSkillsFsApi, + parseSkillMd, + skillNameToDirName, +} from '@/lib/skillToolkit'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { useAuthStore } from './authStore'; + +// Helper function to normalize email to user_id format +// Matches the logic in backend's file_save_path +function emailToUserId(email: string | null): string | null { + if (!email) return null; + return email + .split('@')[0] + .replace(/[\\/*?:"<>|\s]/g, '_') + .replace(/^\.+|\.+$/g, ''); +} + +// Skill scope interface +export interface SkillScope { + isGlobal: boolean; + selectedAgents: string[]; +} + +// Skill interface +export interface Skill { + id: string; + name: string; + description: string; + filePath: string; + fileContent: string; + // Optional: folder name under ~/.eigent/skills + skillDirName?: string; + addedAt: number; + scope: SkillScope; + enabled: boolean; + isExample: boolean; +} + +export const EXAMPLE_SKILL_DIR_NAMES = [ + 'code-reviewer', + 'report-writer', + 'data-analyzer', +] as const; + +// Skills state interface +interface SkillsState { + skills: Skill[]; + addSkill: ( + skill: Omit + ) => Promise; + updateSkill: (id: string, updates: Partial) => Promise; + deleteSkill: (id: string) => Promise; + toggleSkill: (id: string) => Promise; + getSkillsByType: (isExample: boolean) => Skill[]; + // Sync skills from filesystem (Electron) based on SKILL.md files + syncFromDisk: () => Promise; +} + +// Generate unique ID +const generateId = () => + `skill-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + +// Create store +export const useSkillsStore = create()( + persist( + (set, get) => ({ + skills: [], + + addSkill: async (skill) => { + // Persist to filesystem (Electron) as CAMEL-compatible SKILL.md + if (hasSkillsFsApi()) { + const meta = parseSkillMd(skill.fileContent); + const name = meta?.name || skill.name; + const description = meta?.description || skill.description; + const body = meta?.body || skill.fileContent; + const content = buildSkillMd(name, description, body); + const dirName = + skill.skillDirName || skillNameToDirName(name || 'skill'); + window.electronAPI.skillWrite(dirName, content).catch(() => { + // Ignore errors here; UI still holds the in-memory skill + }); + skill = { + ...skill, + filePath: `${dirName}/SKILL.md`, + fileContent: content, + skillDirName: dirName, + }; + } + + const newSkill: Skill = { + ...skill, + id: generateId(), + addedAt: Date.now(), + isExample: false, + }; + + // Update local configuration via Electron IPC + if (hasSkillsFsApi()) { + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (userId) { + await window.electronAPI.skillConfigUpdate( + userId, + newSkill.name, + { + enabled: newSkill.enabled, + scope: newSkill.scope, + addedAt: newSkill.addedAt, + isExample: false, + } + ); + } + } catch (error) { + console.warn('Failed to update skill config:', error); + // Continue anyway - skill is added to UI + } + } + + set((state) => ({ + skills: [newSkill, ...state.skills], + })); + }, + + updateSkill: async (id, updates) => { + const skill = get().skills.find((s) => s.id === id); + if (!skill) return; + + set((state) => ({ + skills: state.skills.map((s) => + s.id === id ? { ...s, ...updates } : s + ), + })); + + // Persist to configuration file if updating scope or enabled status + if ( + hasSkillsFsApi() && + (updates.scope || updates.enabled !== undefined) + ) { + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (!userId) return; + + const updatedSkill = { ...skill, ...updates }; + await window.electronAPI.skillConfigUpdate(userId, skill.name, { + enabled: updatedSkill.enabled, + scope: updatedSkill.scope, + addedAt: updatedSkill.addedAt, + isExample: updatedSkill.isExample, + }); + console.log( + `[Skills] Updated config for skill: ${skill.name}`, + updates + ); + } catch (error) { + console.error('[Skills] Failed to update skill config:', error); + // Revert on error + set((state) => ({ + skills: state.skills.map((s) => (s.id === id ? skill : s)), + })); + } + } + }, + + deleteSkill: async (id) => { + const current = get().skills.find((s) => s.id === id); + if (!current) return; + + // Example skills cannot be deleted, only enabled/disabled + if (current.isExample) return; + + // Delete from filesystem + if (current.skillDirName && hasSkillsFsApi()) { + window.electronAPI.skillDelete(current.skillDirName).catch(() => { + // Ignore deletion errors; state will still be updated + }); + } + + // Delete from local configuration via Electron IPC + if (hasSkillsFsApi()) { + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (userId) { + await window.electronAPI.skillConfigDelete(userId, current.name); + } + } catch (error) { + console.warn('Failed to delete skill config:', error); + // Continue anyway - skill is removed from UI + } + } + + set((state) => ({ + skills: state.skills.filter((skill) => skill.id !== id), + })); + }, + + toggleSkill: async (id) => { + const skill = get().skills.find((s) => s.id === id); + if (!skill) return; + + const newEnabled = !skill.enabled; + + // Optimistically update UI + set((state) => ({ + skills: state.skills.map((s) => + s.id === id ? { ...s, enabled: newEnabled } : s + ), + })); + + // Persist to local configuration via Electron IPC + if (hasSkillsFsApi()) { + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (userId) { + const result = await window.electronAPI.skillConfigToggle( + userId, + skill.name, + newEnabled + ); + if (!result.success) { + throw new Error( + result.error || 'Failed to toggle skill configuration' + ); + } + console.log('Skill configuration updated:', result); + } + } catch (error) { + // Revert on error + console.error('Failed to toggle skill:', error); + set((state) => ({ + skills: state.skills.map((s) => + s.id === id ? { ...s, enabled: !newEnabled } : s + ), + })); + } + } + }, + + getSkillsByType: (isExample) => { + return get().skills.filter((skill) => skill.isExample === isExample); + }, + + // Load skills from ~/.eigent/skills + syncFromDisk: async () => { + if (!hasSkillsFsApi()) return; + try { + const userId = emailToUserId(useAuthStore.getState().email); + + const result = await window.electronAPI.skillsScan(); + if (!result.success || !result.skills) return; + + if (userId) { + console.log(`[Skills] Initializing config for user: ${userId}`); + await window.electronAPI.skillConfigInit(userId); + } + + let config: any = { global: null, project: null }; + try { + if (userId) { + console.log(`[Skills] Loading config for user: ${userId}`); + const result = await window.electronAPI.skillConfigLoad(userId); + if (result.success && result.config) { + config.global = result.config; + console.log( + `[Skills] Loaded config with ${Object.keys(result.config.skills || {}).length} skills configured` + ); + } else { + console.warn('[Skills] Failed to load config:', result.error); + } + } else { + console.warn( + '[Skills] No userId available, skipping config load' + ); + } + } catch (error) { + console.error('[Skills] Error loading skill config:', error); + } + + const prevByKey = new Map( + get().skills.map((s) => [s.skillDirName ?? s.id, s]) + ); + + const diskSkills: Skill[] = result.skills + .map( + (s: { + name: string; + description: string; + path: string; + scope: string; + skillDirName: string; + }) => { + const existing = prevByKey.get(s.skillDirName); + const isExample = ( + EXAMPLE_SKILL_DIR_NAMES as readonly string[] + ).includes(s.skillDirName); + + // Get config from global/project + const globalConfig = config.global?.skills?.[s.name]; + const projectConfig = config.project?.skills?.[s.name]; + const skillConfig = projectConfig ?? globalConfig; + + const enabledFromConfig = skillConfig?.enabled ?? true; + + let scopeFromConfig: SkillScope; + if ( + skillConfig?.scope && + typeof skillConfig.scope === 'object' + ) { + scopeFromConfig = { + isGlobal: skillConfig.scope.isGlobal ?? true, + selectedAgents: skillConfig.scope.selectedAgents ?? [], + }; + } else { + scopeFromConfig = { + isGlobal: true, + selectedAgents: [], + }; + } + + return { + id: `disk-${s.skillDirName}`, + name: s.name, + description: s.description, + filePath: s.path, + fileContent: existing?.fileContent ?? '', + skillDirName: s.skillDirName, + addedAt: + skillConfig?.addedAt ?? existing?.addedAt ?? Date.now(), + scope: scopeFromConfig, + enabled: enabledFromConfig, + isExample: skillConfig?.isExample ?? isExample, + }; + } + ) + .sort((a: Skill, b: Skill) => a.name.localeCompare(b.name)); + + set({ skills: diskSkills }); + } catch { + // Ignore sync errors; keep existing state + } + }, + }), + { + name: 'skills-storage', + partialize: (state) => ({ + skills: state.skills, + }), + } + ) +); + +// Non-hook version for use outside React components +export const getSkillsStore = () => useSkillsStore.getState(); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 91af30006..04648c3cb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -134,6 +134,62 @@ interface ElectronAPI { folderPath: string, ide: string ) => Promise<{ success: boolean; error?: string }>; + // Skills (~/.eigent/skills) + getSkillsDir: () => Promise<{ + success: boolean; + path?: string; + error?: string; + }>; + skillsScan: () => Promise<{ + success: boolean; + skills?: Array<{ + name: string; + description: string; + path: string; + scope: string; + skillDirName: string; + }>; + error?: string; + }>; + skillWrite: ( + skillDirName: string, + content: string + ) => Promise<{ success: boolean; error?: string }>; + skillDelete: ( + skillDirName: string + ) => Promise<{ success: boolean; error?: string }>; + skillRead: ( + filePath: string + ) => Promise<{ success: boolean; content?: string; error?: string }>; + skillListFiles: ( + skillDirName: string + ) => Promise<{ success: boolean; files?: string[]; error?: string }>; + skillImportZip: ( + zipPathOrBuffer: string | ArrayBuffer + ) => Promise<{ success: boolean; error?: string }>; + openSkillFolder: ( + skillName: string + ) => Promise<{ success: boolean; error?: string }>; + skillConfigInit: ( + userId: string + ) => Promise<{ success: boolean; config?: any; error?: string }>; + skillConfigLoad: ( + userId: string + ) => Promise<{ success: boolean; config?: any; error?: string }>; + skillConfigToggle: ( + userId: string, + skillName: string, + enabled: boolean + ) => Promise<{ success: boolean; config?: any; error?: string }>; + skillConfigUpdate: ( + userId: string, + skillName: string, + skillConfig: any + ) => Promise<{ success: boolean; error?: string }>; + skillConfigDelete: ( + userId: string, + skillName: string + ) => Promise<{ success: boolean; error?: string }>; } declare global {