From b119bcf540ed33a579863393aefc2400f0ad0721 Mon Sep 17 00:00:00 2001 From: Pakchoioioi Date: Wed, 4 Feb 2026 02:05:34 +0200 Subject: [PATCH 01/12] skills_upload_prototype --- src/components/ChatBox/index.tsx | 48 ++- src/components/SearchInput/index.tsx | 9 +- src/i18n/locales/ar/capabilities.json | 45 +++ src/i18n/locales/ar/index.ts | 2 + src/i18n/locales/ar/layout.json | 5 +- src/i18n/locales/de/capabilities.json | 45 +++ src/i18n/locales/de/index.ts | 2 + src/i18n/locales/de/layout.json | 5 +- src/i18n/locales/en-us/capabilities.json | 45 +++ src/i18n/locales/en-us/index.ts | 2 + src/i18n/locales/en-us/layout.json | 5 +- src/i18n/locales/es/capabilities.json | 45 +++ src/i18n/locales/es/index.ts | 2 + src/i18n/locales/es/layout.json | 5 +- src/i18n/locales/fr/capabilities.json | 45 +++ src/i18n/locales/fr/index.ts | 2 + src/i18n/locales/fr/layout.json | 5 +- src/i18n/locales/it/capabilities.json | 45 +++ src/i18n/locales/it/index.ts | 2 + src/i18n/locales/it/layout.json | 5 +- src/i18n/locales/ja/capabilities.json | 45 +++ src/i18n/locales/ja/index.ts | 2 + src/i18n/locales/ja/layout.json | 5 +- src/i18n/locales/ko/capabilities.json | 45 +++ src/i18n/locales/ko/index.ts | 2 + src/i18n/locales/ko/layout.json | 5 +- src/i18n/locales/ru/capabilities.json | 45 +++ src/i18n/locales/ru/index.ts | 2 + src/i18n/locales/ru/layout.json | 5 +- src/i18n/locales/zh-Hans/capabilities.json | 45 +++ src/i18n/locales/zh-Hans/index.ts | 2 + src/i18n/locales/zh-Hans/layout.json | 5 +- src/i18n/locales/zh-Hant/capabilities.json | 45 +++ src/i18n/locales/zh-Hant/index.ts | 2 + src/i18n/locales/zh-Hant/layout.json | 5 +- src/pages/Capabilities/Memory.tsx | 44 +++ src/pages/Capabilities/Skills.tsx | 214 +++++++++++++ .../components/SkillDeleteDialog.tsx | 73 +++++ .../Capabilities/components/SkillListItem.tsx | 136 +++++++++ .../components/SkillScopeSelect.tsx | 181 +++++++++++ .../components/SkillUploadDialog.tsx | 265 ++++++++++++++++ src/pages/Capabilities/index.tsx | 80 +++++ src/pages/History.tsx | 288 ++++++++++-------- src/store/skillsStore.ts | 163 ++++++++++ 44 files changed, 1931 insertions(+), 142 deletions(-) create mode 100644 src/i18n/locales/ar/capabilities.json create mode 100644 src/i18n/locales/de/capabilities.json create mode 100644 src/i18n/locales/en-us/capabilities.json create mode 100644 src/i18n/locales/es/capabilities.json create mode 100644 src/i18n/locales/fr/capabilities.json create mode 100644 src/i18n/locales/it/capabilities.json create mode 100644 src/i18n/locales/ja/capabilities.json create mode 100644 src/i18n/locales/ko/capabilities.json create mode 100644 src/i18n/locales/ru/capabilities.json create mode 100644 src/i18n/locales/zh-Hans/capabilities.json create mode 100644 src/i18n/locales/zh-Hant/capabilities.json create mode 100644 src/pages/Capabilities/Memory.tsx create mode 100644 src/pages/Capabilities/Skills.tsx create mode 100644 src/pages/Capabilities/components/SkillDeleteDialog.tsx create mode 100644 src/pages/Capabilities/components/SkillListItem.tsx create mode 100644 src/pages/Capabilities/components/SkillScopeSelect.tsx create mode 100644 src/pages/Capabilities/components/SkillUploadDialog.tsx create mode 100644 src/pages/Capabilities/index.tsx create mode 100644 src/store/skillsStore.ts diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 2c172496c..3224ff7b8 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -23,6 +23,7 @@ import { import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { generateUniqueId, replayActiveTask } from '@/lib'; import { useAuthStore } from '@/store/authStore'; +import { AgentStep, ChatTaskStatus } from '@/types/constants'; import { Square, SquareCheckBig, TriangleAlert } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -30,7 +31,6 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import BottomBox from './BottomBox'; import { ProjectChatContainer } from './ProjectChatContainer'; -import { AgentStep, ChatTaskStatus } from '@/types/constants'; export default function ChatBox(): JSX.Element { const [message, setMessage] = useState(''); @@ -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); @@ -257,7 +258,9 @@ export default function ChatBox(): JSX.Element { task.status === ChatTaskStatus.RUNNING || task.status === ChatTaskStatus.PAUSE || // splitting phase - task.messages.some((m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm) || + task.messages.some( + (m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm + ) || // skeleton/computing phase (!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS) && !task.hasWaitComfirm && @@ -351,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); @@ -428,13 +442,17 @@ export default function ChatBox(): JSX.Element { (task.status === ChatTaskStatus.RUNNING && task.hasMessages) || task.status === ChatTaskStatus.PAUSE || // splitting phase: has to_sub_tasks not confirmed OR skeleton computing - task.messages.some((m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm) || + task.messages.some( + (m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm + ) || (!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS) && !task.hasWaitComfirm && task.messages.length > 0) || task.isTakeControl || // explicit confirm wait while task is pending but card not confirmed yet - (!!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm) && + (!!task.messages.find( + (m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm + ) && task.status === ChatTaskStatus.PENDING); const isReplayChatStore = task?.type === 'replay'; if (!requiresHumanReply && isTaskBusy && !isReplayChatStore) { @@ -509,7 +527,8 @@ export default function ChatBox(): JSX.Element { (hasWaitComfirm && !wasTaskStopped) || (isFinished && !wasTaskStopped) || (hasMessages && - chatStore.tasks[_taskId as string].status === ChatTaskStatus.PENDING); + chatStore.tasks[_taskId as string].status === + ChatTaskStatus.PENDING); if (shouldContinueConversation) { // Check if this is the very first message and task hasn't started @@ -528,7 +547,8 @@ export default function ChatBox(): JSX.Element { // Only start a new task if: pending, no messages processed yet // OR while or after replaying a project if ( - (chatStore.tasks[_taskId as string].status === ChatTaskStatus.PENDING && + (chatStore.tasks[_taskId as string].status === + ChatTaskStatus.PENDING && !hasSimpleResponse && !hasComplexTask && !isFinished) || @@ -891,7 +911,10 @@ export default function ChatBox(): JSX.Element { } // Check task status - if (task.status === ChatTaskStatus.RUNNING || task.status === ChatTaskStatus.PAUSE) { + if ( + task.status === ChatTaskStatus.RUNNING || + task.status === ChatTaskStatus.PAUSE + ) { return 'running'; } @@ -939,7 +962,11 @@ export default function ChatBox(): JSX.Element { // Note: Replay creates a new chatstore, so no conflicts const task = chatStore.tasks[chatStore.activeTaskId as string]; // Only skip backend call if task is finished or hasn't started yet (no messages) - if (task && task.messages.length > 0 && task.status !== ChatTaskStatus.FINISHED) { + if ( + task && + task.messages.length > 0 && + task.status !== ChatTaskStatus.FINISHED + ) { try { await fetchDelete(`/chat/${project_id}/remove-task/${task_id}`, { project_id: project_id, @@ -1011,7 +1038,8 @@ export default function ChatBox(): JSX.Element { taskStatus={chatStore.tasks[chatStore.activeTaskId]?.status} onReplay={handleReplay} replayDisabled={ - chatStore.tasks[chatStore.activeTaskId]?.status !== ChatTaskStatus.FINISHED + chatStore.tasks[chatStore.activeTaskId]?.status !== + ChatTaskStatus.FINISHED } replayLoading={isReplayLoading} onPauseResume={handlePauseResume} diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index 2a1c5cf1d..ad13607cb 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -19,9 +19,14 @@ import { useTranslation } from 'react-i18next'; interface SearchInputProps { value: string; onChange: (e: React.ChangeEvent) => void; + placeholder?: string; } -export default function SearchInput({ value, onChange }: SearchInputProps) { +export default function SearchInput({ + value, + onChange, + placeholder, +}: SearchInputProps) { const { t } = useTranslation(); return (
@@ -29,7 +34,7 @@ export default function SearchInput({ value, onChange }: SearchInputProps) { size="sm" value={value} onChange={onChange} - placeholder={t('setting.search-mcp')} + placeholder={placeholder || t('setting.search-mcp')} leadingIcon={} />
diff --git a/src/i18n/locales/ar/capabilities.json b/src/i18n/locales/ar/capabilities.json new file mode 100644 index 000000000..cf201b3f1 --- /dev/null +++ b/src/i18n/locales/ar/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "المهارات", + "memory": "الذاكرة", + "preview": "معاينة", + "skills-description": "أضف مهارات مخصصة لتوسيع قدرات الوكيل الخاص بك.", + "memory-description": "إدارة ذاكرة الوكيل وقاعدة المعرفة الخاصة به.", + "memory-coming-soon-description": "ستتيح ميزات الذاكرة لوكلائك تذكر المعلومات المهمة عبر الجلسات.", + "learn-more": "معرفة المزيد", + "search-skills": "البحث عن المهارات...", + "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": "فشل في قراءة الملف. يرجى المحاولة مرة أخرى.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/ar/layout.json b/src/i18n/locales/ar/layout.json index 2216579bd..76eb360cd 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/capabilities.json b/src/i18n/locales/de/capabilities.json new file mode 100644 index 000000000..b480f3379 --- /dev/null +++ b/src/i18n/locales/de/capabilities.json @@ -0,0 +1,45 @@ +{ + "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...", + "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.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/de/layout.json b/src/i18n/locales/de/layout.json index dabf8a975..40e627416 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/capabilities.json b/src/i18n/locales/en-us/capabilities.json new file mode 100644 index 000000000..847bb135b --- /dev/null +++ b/src/i18n/locales/en-us/capabilities.json @@ -0,0 +1,45 @@ +{ + "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...", + "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.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index 69b2994b6..645bdbb33 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/es/capabilities.json b/src/i18n/locales/es/capabilities.json new file mode 100644 index 000000000..35b6f330b --- /dev/null +++ b/src/i18n/locales/es/capabilities.json @@ -0,0 +1,45 @@ +{ + "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...", + "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.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/es/layout.json b/src/i18n/locales/es/layout.json index 7bca0ebeb..51002d0dc 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/capabilities.json b/src/i18n/locales/fr/capabilities.json new file mode 100644 index 000000000..3c046d46c --- /dev/null +++ b/src/i18n/locales/fr/capabilities.json @@ -0,0 +1,45 @@ +{ + "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...", + "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.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 44f9c1929..67430397e 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/capabilities.json b/src/i18n/locales/it/capabilities.json new file mode 100644 index 000000000..0e8405f2f --- /dev/null +++ b/src/i18n/locales/it/capabilities.json @@ -0,0 +1,45 @@ +{ + "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...", + "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.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/it/layout.json b/src/i18n/locales/it/layout.json index 350dbc1ef..3b811cf14 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/capabilities.json b/src/i18n/locales/ja/capabilities.json new file mode 100644 index 000000000..6d40bc094 --- /dev/null +++ b/src/i18n/locales/ja/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "スキル", + "memory": "メモリ", + "preview": "プレビュー", + "skills-description": "カスタムスキルを追加してエージェントの能力を拡張します。", + "memory-description": "エージェントのメモリとナレッジベースを管理します。", + "memory-coming-soon-description": "メモリ機能により、エージェントがセッション間で重要な情報を記憶できるようになります。", + "learn-more": "詳細を見る", + "search-skills": "スキルを検索...", + "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": "ファイルの読み込みに失敗しました。もう一度お試しください。", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/ja/layout.json b/src/i18n/locales/ja/layout.json index d049379db..508771452 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/capabilities.json b/src/i18n/locales/ko/capabilities.json new file mode 100644 index 000000000..faca71e95 --- /dev/null +++ b/src/i18n/locales/ko/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "스킬", + "memory": "메모리", + "preview": "미리보기", + "skills-description": "사용자 정의 스킬을 추가하여 에이전트 기능을 확장하세요.", + "memory-description": "에이전트의 메모리와 지식 베이스를 관리하세요.", + "memory-coming-soon-description": "메모리 기능을 통해 에이전트가 세션 간에 중요한 정보를 기억할 수 있습니다.", + "learn-more": "자세히 알아보기", + "search-skills": "스킬 검색...", + "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": "파일 읽기에 실패했습니다. 다시 시도해 주세요.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/ko/layout.json b/src/i18n/locales/ko/layout.json index 3d0a05c24..56d55b6f6 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/capabilities.json b/src/i18n/locales/ru/capabilities.json new file mode 100644 index 000000000..8ea9de71d --- /dev/null +++ b/src/i18n/locales/ru/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "Навыки", + "memory": "Память", + "preview": "Предпросмотр", + "skills-description": "Добавьте пользовательские навыки для расширения возможностей вашего агента.", + "memory-description": "Управляйте памятью и базой знаний вашего агента.", + "memory-coming-soon-description": "Функции памяти позволят вашим агентам запоминать важную информацию между сеансами.", + "learn-more": "Узнать больше", + "search-skills": "Поиск навыков...", + "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": "Не удалось прочитать файл. Пожалуйста, попробуйте снова.", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index f6bb087f1..8d524ae8d 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/capabilities.json b/src/i18n/locales/zh-Hans/capabilities.json new file mode 100644 index 000000000..81099d716 --- /dev/null +++ b/src/i18n/locales/zh-Hans/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "技能", + "memory": "记忆", + "preview": "预览", + "skills-description": "添加自定义技能以扩展您的智能体能力。", + "memory-description": "管理您的智能体的记忆和知识库。", + "memory-coming-soon-description": "记忆功能将允许您的智能体在会话之间记住重要信息。", + "learn-more": "了解更多", + "search-skills": "搜索技能...", + "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": "读取文件失败。请重试。", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hans/layout.json b/src/i18n/locales/zh-Hans/layout.json index 405b635fe..62ea6458e 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/capabilities.json b/src/i18n/locales/zh-Hant/capabilities.json new file mode 100644 index 000000000..dff4182b5 --- /dev/null +++ b/src/i18n/locales/zh-Hant/capabilities.json @@ -0,0 +1,45 @@ +{ + "skills": "技能", + "memory": "記憶", + "preview": "預覽", + "skills-description": "新增自訂技能以擴展您的智能體能力。", + "memory-description": "管理您的智能體的記憶和知識庫。", + "memory-coming-soon-description": "記憶功能將允許您的智能體在會話之間記住重要資訊。", + "learn-more": "了解更多", + "search-skills": "搜尋技能...", + "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": "讀取檔案失敗。請重試。", + "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..f00c65d93 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 capabilities from './capabilities.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 { + capabilities, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hant/layout.json b/src/i18n/locales/zh-Hant/layout.json index 944e37162..ffc864f91 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/pages/Capabilities/Memory.tsx b/src/pages/Capabilities/Memory.tsx new file mode 100644 index 000000000..2451d606f --- /dev/null +++ b/src/pages/Capabilities/Memory.tsx @@ -0,0 +1,44 @@ +// ========= 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('capabilities.memory')} +
+
+ + {/* Coming Soon Card */} +
+
+ +
+

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

+

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

+
+
+ ); +} diff --git a/src/pages/Capabilities/Skills.tsx b/src/pages/Capabilities/Skills.tsx new file mode 100644 index 000000000..8d0d73706 --- /dev/null +++ b/src/pages/Capabilities/Skills.tsx @@ -0,0 +1,214 @@ +// ========= 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 { useSkillsStore, type Skill } from '@/store/skillsStore'; +import { ChevronDown, ChevronUp, Plus } from 'lucide-react'; +import { 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 } = useSkillsStore(); + const [searchQuery, setSearchQuery] = useState(''); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [skillToDelete, setSkillToDelete] = useState(null); + const [collapsedYourSkills, setCollapsedYourSkills] = useState(false); + const [collapsedExampleSkills, setCollapsedExampleSkills] = useState(false); + + 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('capabilities.skills')} +
+
+ setSearchQuery(e.target.value)} + placeholder={t('capabilities.search-skills')} + /> + +
+
+ + {/* Your Skills Section */} +
+
+ + {t('capabilities.your-skills')} + + +
+ {!collapsedYourSkills && ( + <> + {yourSkills.length === 0 ? ( +
+

+ {searchQuery + ? t('capabilities.no-skills-found') + : t('capabilities.no-your-skills')} +

+ {!searchQuery && ( + + )} +
+ ) : ( +
+ {yourSkills.map((skill) => ( + handleDeleteClick(skill)} + /> + ))} +
+ )} + + )} +
+ + {/* Example Skills Section */} +
+
+ + {t('capabilities.example-skills')} + + +
+ {!collapsedExampleSkills && ( + <> + {exampleSkills.length === 0 ? ( +
+

+ {searchQuery + ? t('capabilities.no-skills-found') + : t('capabilities.no-example-skills')} +

+
+ ) : ( +
+ {exampleSkills.map((skill) => ( + handleDeleteClick(skill)} + /> + ))} +
+ )} + + )} +
+ + {/* Upload Dialog */} + setUploadDialogOpen(false)} + /> + + {/* Delete Dialog */} + +
+ ); +} diff --git a/src/pages/Capabilities/components/SkillDeleteDialog.tsx b/src/pages/Capabilities/components/SkillDeleteDialog.tsx new file mode 100644 index 000000000..19c397642 --- /dev/null +++ b/src/pages/Capabilities/components/SkillDeleteDialog.tsx @@ -0,0 +1,73 @@ +// ========= 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 { + Dialog, + DialogContent, + DialogContentSection, + DialogFooter, + DialogHeader, +} from '@/components/ui/dialog'; +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('capabilities.skill-deleted-success')); + } + onConfirm(); + }; + + return ( + !isOpen && onCancel()}> + + + +

+ {t('capabilities.delete-skill-confirmation', { + name: skill?.name || '', + })} +

+
+ +
+
+ ); +} diff --git a/src/pages/Capabilities/components/SkillListItem.tsx b/src/pages/Capabilities/components/SkillListItem.tsx new file mode 100644 index 000000000..254044aa1 --- /dev/null +++ b/src/pages/Capabilities/components/SkillListItem.tsx @@ -0,0 +1,136 @@ +// ========= 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 { Switch } from '@/components/ui/switch'; +import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useSkillsStore, type Skill } from '@/store/skillsStore'; +import { Ellipsis, MessageSquare, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import SkillScopeSelect from './SkillScopeSelect'; + +interface SkillListItemProps { + skill: Skill; + onDelete: () => void; +} + +export default function SkillListItem({ skill, onDelete }: SkillListItemProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { toggleSkill, updateSkill } = useSkillsStore(); + const { projectStore } = useChatStoreAdapter(); + + 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 handleToggle = () => { + toggleSkill(skill.id); + }; + + const handleScopeChange = (scope: { + isGlobal: boolean; + selectedAgents: string[]; + }) => { + updateSkill(skill.id, { scope }); + }; + + 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 ( +
+ {/* Left side: Status dot + Info */} +
+ {/* Status indicator dot */} +
+ {/* Name and description */} +
+ + {skill.name} + +

+ {skill.description} +

+ + {t('capabilities.added')} {formatDate(skill.addedAt)} + +
+
+ + {/* Right side: Controls */} +
+ {/* Scope Select */} + + + {/* Enable/Disable Switch */} + + + {/* More Actions Menu (三个点) */} + + + + + + + + {t('capabilities.try-in-chat')} + + + + {t('layout.delete')} + + + +
+
+ ); +} diff --git a/src/pages/Capabilities/components/SkillScopeSelect.tsx b/src/pages/Capabilities/components/SkillScopeSelect.tsx new file mode 100644 index 000000000..ea32bc1ed --- /dev/null +++ b/src/pages/Capabilities/components/SkillScopeSelect.tsx @@ -0,0 +1,181 @@ +// ========= 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 { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { useWorkerList } from '@/store/authStore'; +import type { SkillScope } from '@/store/skillsStore'; +import { Check, ChevronDown, Globe, Users } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +// Default agent types that are always available +const DEFAULT_AGENTS = [ + 'Developer Agent', + 'Browser Agent', + 'Multi-modal Agent', + 'Document Agent', +]; + +// Special identifier for Global option +const GLOBAL_OPTION = '__GLOBAL__'; + +interface SkillScopeSelectProps { + scope: SkillScope; + onChange: (scope: SkillScope) => void; + disabled?: boolean; +} + +export default function SkillScopeSelect({ + scope, + onChange, + disabled = false, +}: SkillScopeSelectProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const workerList = useWorkerList(); + + // Combine default agents with user-configured workers + // New workers will automatically appear since workerList is reactive + const allAgents = useMemo(() => { + const workerNames = workerList.map((w) => w.name); + // Combine default agents with workers, avoiding duplicates + const combined = [...DEFAULT_AGENTS]; + workerNames.forEach((name) => { + if (!combined.includes(name)) { + combined.push(name); + } + }); + return combined; + }, [workerList]); + + // Handle toggle for any option (including Global) + const handleToggle = (optionName: string) => { + if (optionName === GLOBAL_OPTION) { + // Toggle Global + onChange({ + isGlobal: !scope.isGlobal, + selectedAgents: scope.selectedAgents, + }); + } else { + // Toggle agent + const isSelected = scope.selectedAgents.includes(optionName); + let newSelectedAgents: string[]; + + if (isSelected) { + newSelectedAgents = scope.selectedAgents.filter( + (a) => a !== optionName + ); + } else { + newSelectedAgents = [...scope.selectedAgents, optionName]; + } + + onChange({ + isGlobal: scope.isGlobal, + selectedAgents: newSelectedAgents, + }); + } + }; + + const getDisplayText = () => { + const selections: string[] = []; + + if (scope.isGlobal) { + selections.push(t('capabilities.global')); + } + + selections.push(...scope.selectedAgents); + + if (selections.length === 0) { + return t('capabilities.select-scope'); + } + if (selections.length === 1) { + return selections[0]; + } + return `${selections.length} ${t('capabilities.selected')}`; + }; + + const hasSelection = scope.isGlobal || scope.selectedAgents.length > 0; + + return ( +
+ + {t('capabilities.skill-scope')} + + + + + + +
+ {/* Global Option - same level as agents */} + + + {/* Agent/Worker List - Multi-select, same level as Global */} + {allAgents.map((agentName) => { + const isSelected = scope.selectedAgents.includes(agentName); + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/pages/Capabilities/components/SkillUploadDialog.tsx b/src/pages/Capabilities/components/SkillUploadDialog.tsx new file mode 100644 index 000000000..a1eeb7a69 --- /dev/null +++ b/src/pages/Capabilities/components/SkillUploadDialog.tsx @@ -0,0 +1,265 @@ +// ========= 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, + DialogFooter, + DialogHeader, +} from '@/components/ui/dialog'; +import { useSkillsStore } from '@/store/skillsStore'; +import { 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 } = 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 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) => { + // Validate file type + const validExtensions = ['.skill', '.md', '.txt', '.json']; + const extension = file.name + .substring(file.name.lastIndexOf('.')) + .toLowerCase(); + + if (!validExtensions.includes(extension)) { + toast.error(t('capabilities.invalid-file-type')); + return; + } + + // Validate file size (max 1MB) + if (file.size > 1024 * 1024) { + toast.error(t('capabilities.file-too-large')); + return; + } + + try { + const content = await file.text(); + setSelectedFile(file); + setFileContent(content); + } catch (_error) { + toast.error(t('capabilities.file-read-error')); + } + }, + [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 handleUpload = async () => { + if (!selectedFile || !fileContent) return; + + setIsUploading(true); + try { + // Parse skill name from file name or content + const fileName = selectedFile.name.replace(/\.[^/.]+$/, ''); + + // Try to extract name and description from content + let name = fileName; + let description = ''; + + // If it's a markdown file, try to parse the first heading and paragraph + if (fileContent.startsWith('#')) { + const lines = fileContent.split('\n'); + const headingMatch = lines[0].match(/^#\s+(.+)/); + if (headingMatch) { + name = headingMatch[1]; + } + // Find first non-empty, non-heading line for description + 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('capabilities.custom-skill'), + filePath: selectedFile.name, + fileContent, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: true, + }); + + toast.success(t('capabilities.skill-added-success')); + handleClose(); + } catch (_error) { + toast.error(t('capabilities.skill-add-error')); + } finally { + setIsUploading(false); + } + }; + + const handleClose = () => { + setSelectedFile(null); + setFileContent(''); + setIsDragging(false); + onClose(); + }; + + const handleRemoveFile = () => { + setSelectedFile(null); + setFileContent(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( + !isOpen && handleClose()}> + + + +
+ {/* Drop Zone */} +
fileInputRef.current?.click()} + > + + + {selectedFile ? ( +
+
+ +
+
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(1)} KB +

+
+ +
+ ) : ( +
+
+ +
+
+

+ {t('capabilities.drag-and-drop')} +

+

+ {t('capabilities.or-click-to-browse')} +

+
+
+ )} +
+ + {/* File Requirements */} +
+

+ {t('capabilities.file-requirements')} +

+
    +
  • + + {t('capabilities.file-requirements-detail-1')} +
  • +
  • + + {t('capabilities.file-requirements-detail-2')} +
  • +
+
+
+
+ +
+
+ ); +} diff --git a/src/pages/Capabilities/index.tsx b/src/pages/Capabilities/index.tsx new file mode 100644 index 000000000..e2db2bc11 --- /dev/null +++ b/src/pages/Capabilities/index.tsx @@ -0,0 +1,80 @@ +// ========= 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 Models from '@/pages/Setting/Models'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Memory from './Memory'; +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('capabilities.skills'), + }, + { + id: 'memory', + name: t('capabilities.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 9e9553919..c1decf84d 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -12,142 +12,192 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { useMemo, useRef, useState } from "react"; -import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; -import { Plus } from "lucide-react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { useTranslation } from "react-i18next"; -import { useAuthStore } from "@/store/authStore"; -import { MenuToggleGroup, MenuToggleItem } from "@/components/MenuButton/MenuButton"; -import Project from "@/pages/Dashboard/Project"; -import AlertDialog from "@/components/ui/alertDialog"; -import { Settings } from "@/components/animate-ui/icons/settings"; -import { Compass } from "@/components/animate-ui/icons/compass"; -import Setting from "@/pages/Setting"; -import { Hammer } from "@/components/animate-ui/icons/hammer"; -import MCP from "./Setting/MCP"; -import Browser from "./Dashboard/Browser"; -import WordCarousel from "@/components/ui/WordCarousel"; -import { Sparkle } from "@/components/animate-ui/icons/sparkle"; +import { Compass } from '@/components/animate-ui/icons/compass'; +import { Hammer } from '@/components/animate-ui/icons/hammer'; +import { Settings } from '@/components/animate-ui/icons/settings'; +import { Sparkle } from '@/components/animate-ui/icons/sparkle'; +import { + MenuToggleGroup, + MenuToggleItem, +} from '@/components/MenuButton/MenuButton'; +import AlertDialog from '@/components/ui/alertDialog'; +import { Button } from '@/components/ui/button'; +import WordCarousel from '@/components/ui/WordCarousel'; +import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import Project from '@/pages/Dashboard/Project'; +import Setting from '@/pages/Setting'; +import { useAuthStore } from '@/store/authStore'; +import { Layers, Plus } from 'lucide-react'; +import { useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import Capabilities from './Capabilities'; +import Browser from './Dashboard/Browser'; +import MCP from './Setting/MCP'; const VALID_TABS = [ - 'projects', - 'workers', - 'trigger', - 'settings', - 'mcp_tools', - 'browser', + 'projects', + 'workers', + 'trigger', + 'settings', + 'mcp_tools', + 'browser', + 'capabilities', ] as const; type TabType = (typeof VALID_TABS)[number]; export default function Home() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { chatStore, projectStore } = useChatStoreAdapter(); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); - const scrollContainerRef = useRef(null); - const { username, email } = useAuthStore(); - const displayName = username ?? email ?? ""; + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { chatStore, projectStore } = useChatStoreAdapter(); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const scrollContainerRef = useRef(null); + const { username, email } = useAuthStore(); + const displayName = username ?? email ?? ''; - // Compute activeTab from URL, fallback to 'projects' if not in URL or invalid - const activeTab = useMemo(() => { - const tabFromUrl = searchParams.get('tab'); - if (tabFromUrl && VALID_TABS.includes(tabFromUrl as TabType)) { - return tabFromUrl as TabType; - } - return 'projects' as TabType; - }, [searchParams]); + // Compute activeTab from URL, fallback to 'projects' if not in URL or invalid + const activeTab = useMemo(() => { + const tabFromUrl = searchParams.get('tab'); + if (tabFromUrl && VALID_TABS.includes(tabFromUrl as TabType)) { + return tabFromUrl as TabType; + } + return 'projects' as TabType; + }, [searchParams]); - const handleTabChange = (value: string) => { - if (value) { - navigate(`?tab=${value}`, { replace: true }); - } - }; + const handleTabChange = (value: string) => { + if (value) { + navigate(`?tab=${value}`, { replace: true }); + } + }; - const formatWelcomeName = (raw: string): string => { - if (!raw) return ""; - if (/^[^@]+@gmail\.com$/i.test(raw)) { - const local = raw.split("@")[0]; - const pretty = local.replace(/[._-]+/g, " ").trim(); - return pretty - .split(/\s+/) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - } - return raw; - }; + const formatWelcomeName = (raw: string): string => { + if (!raw) return ''; + if (/^[^@]+@gmail\.com$/i.test(raw)) { + const local = raw.split('@')[0]; + const pretty = local.replace(/[._-]+/g, ' ').trim(); + return pretty + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + } + return raw; + }; - const welcomeName = formatWelcomeName(displayName); + const welcomeName = formatWelcomeName(displayName); - const confirmDelete = () => { - setDeleteModalOpen(false); - }; + const confirmDelete = () => { + setDeleteModalOpen(false); + }; - // create task - const createChat = () => { - //Handles refocusing id & non duplicate logic internally - projectStore?.createProject("new project"); - navigate("/"); - }; + // create task + const createChat = () => { + //Handles refocusing id & non duplicate logic internally + projectStore?.createProject('new project'); + navigate('/'); + }; - if (!chatStore || !projectStore) { - return
Loading...
; - } + if (!chatStore || !projectStore) { + return
Loading...
; + } - return ( -
- {/* alert dialog */} - setDeleteModalOpen(false)} - onConfirm={confirmDelete} - title={t("layout.delete-task")} - message={t("layout.delete-task-confirmation")} - confirmText={t("layout.delete")} - cancelText={t("layout.cancel")} - /> - {/* welcome text */} -
- + {/* alert dialog */} + setDeleteModalOpen(false)} + onConfirm={confirmDelete} + title={t('layout.delete-task')} + message={t('layout.delete-task-confirmation')} + confirmText={t('layout.delete')} + cancelText={t('layout.cancel')} + /> + {/* welcome text */} +
+ -
- {/* Navbar */} -
-
-
- - }>{t("layout.projects")} - }>{t("layout.mcp-tools")} - }>{t("layout.browser")} - }>{t("layout.settings")} - -
- -
-
- {activeTab === "projects" && } - {activeTab === "mcp_tools" && } - {activeTab === "browser" && } - {activeTab === "settings" && } -
- ); + ariaLabel="rotating headline" + /> +
+ {/* Navbar */} +
+
+
+ + } + > + {t('layout.projects')} + + } + > + {t('layout.connectors')} + + } + > + {t('layout.browser')} + + } + > + {t('layout.capabilities')} + + } + > + {t('layout.general')} + + +
+ +
+
+ {activeTab === 'projects' && } + {activeTab === 'mcp_tools' && } + {activeTab === 'browser' && } + {activeTab === 'settings' && } + {activeTab === 'capabilities' && } +
+ ); } diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts new file mode 100644 index 000000000..0a5640afc --- /dev/null +++ b/src/store/skillsStore.ts @@ -0,0 +1,163 @@ +// ========= 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 { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +// 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; + addedAt: number; + scope: SkillScope; + enabled: boolean; + isExample: boolean; +} + +// Example skills +const EXAMPLE_SKILLS: Skill[] = [ + { + id: 'example-web-scraper', + name: 'Web Scraper', + description: + 'Extract data from web pages using CSS selectors or XPath expressions', + filePath: 'examples/web-scraper.skill', + fileContent: `# Web Scraper Skill +This skill allows the agent to scrape web pages and extract structured data. + +## Usage +- Extract text content from specific elements +- Parse tables and lists +- Follow pagination links`, + addedAt: Date.now() - 86400000 * 7, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: true, + isExample: true, + }, + { + id: 'example-file-organizer', + name: 'File Organizer', + description: + 'Automatically organize files into folders based on type, date, or custom rules', + filePath: 'examples/file-organizer.skill', + fileContent: `# File Organizer Skill +This skill helps organize files in directories based on various criteria. + +## Features +- Sort by file type (images, documents, videos) +- Sort by date created/modified +- Custom folder naming rules`, + addedAt: Date.now() - 86400000 * 14, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: true, + isExample: true, + }, + { + id: 'example-data-analyzer', + name: 'Data Analyzer', + description: + 'Analyze CSV and JSON data files to generate insights and visualizations', + filePath: 'examples/data-analyzer.skill', + fileContent: `# Data Analyzer Skill +This skill enables data analysis capabilities for the agent. + +## Capabilities +- Read and parse CSV/JSON files +- Calculate statistics (mean, median, mode) +- Generate summary reports`, + addedAt: Date.now() - 86400000 * 21, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: false, + isExample: true, + }, +]; + +// Skills state interface +interface SkillsState { + skills: Skill[]; + addSkill: (skill: Omit) => void; + updateSkill: (id: string, updates: Partial) => void; + deleteSkill: (id: string) => void; + toggleSkill: (id: string) => void; + getSkillsByType: (isExample: boolean) => Skill[]; +} + +// 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: [...EXAMPLE_SKILLS], + + addSkill: (skill) => { + const newSkill: Skill = { + ...skill, + id: generateId(), + addedAt: Date.now(), + isExample: false, + }; + set((state) => ({ + skills: [newSkill, ...state.skills], + })); + }, + + updateSkill: (id, updates) => { + set((state) => ({ + skills: state.skills.map((skill) => + skill.id === id ? { ...skill, ...updates } : skill + ), + })); + }, + + deleteSkill: (id) => { + set((state) => ({ + skills: state.skills.filter((skill) => skill.id !== id), + })); + }, + + toggleSkill: (id) => { + set((state) => ({ + skills: state.skills.map((skill) => + skill.id === id ? { ...skill, enabled: !skill.enabled } : skill + ), + })); + }, + + getSkillsByType: (isExample) => { + return get().skills.filter((skill) => skill.isExample === isExample); + }, + }), + { + name: 'skills-storage', + partialize: (state) => ({ + skills: state.skills, + }), + } + ) +); + +// Non-hook version for use outside React components +export const getSkillsStore = () => useSkillsStore.getState(); From 637310cebcb753eaca4a328289087a22b2d9cbde Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Wed, 11 Feb 2026 11:43:02 +0800 Subject: [PATCH 02/12] WIP: skill_toolkit --- backend/app/agent/factory/browser.py | 10 + backend/app/agent/factory/developer.py | 10 + backend/app/agent/factory/document.py | 10 + backend/app/agent/factory/multi_modal.py | 10 + backend/app/agent/prompt.py | 28 ++ backend/app/agent/toolkit/skill_toolkit.py | 153 +++++++++++ backend/app/service/chat_service.py | 59 +++- backend/app/utils/file_utils.py | 39 +++ backend/pyproject.toml | 2 +- backend/uv.lock | 11 +- electron-builder.json | 4 + electron/main/index.ts | 254 ++++++++++++++++++ electron/preload/index.ts | 14 + .../example-skills/code-reviewer/SKILL.md | 46 ++++ .../example-skills/data-analyzer/SKILL.md | 40 +++ .../data-analyzer/scripts/analyze.py | 35 +++ .../example-skills/report-writer/SKILL.md | 50 ++++ .../ChatBox/MessageItem/UserMessageCard.tsx | 55 +++- src/lib/skillToolkit.ts | 132 +++++++++ src/pages/Capabilities/Skills.tsx | 10 +- .../Capabilities/components/SkillListItem.tsx | 2 +- .../components/SkillUploadDialog.tsx | 69 +++-- src/store/skillsStore.ts | 151 +++++++---- src/types/electron.d.ts | 36 +++ 24 files changed, 1137 insertions(+), 93 deletions(-) create mode 100644 backend/app/agent/toolkit/skill_toolkit.py create mode 100644 resources/example-skills/code-reviewer/SKILL.md create mode 100644 resources/example-skills/data-analyzer/SKILL.md create mode 100644 resources/example-skills/data-analyzer/scripts/analyze.py create mode 100644 resources/example-skills/report-writer/SKILL.md create mode 100644 src/lib/skillToolkit.ts 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/prompt.py b/backend/app/agent/prompt.py index ee1d782d0..392b3a0e5 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -142,6 +142,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 +270,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 +427,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 +602,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..2a9f9d68d --- /dev/null +++ b/backend/app/agent/toolkit/skill_toolkit.py @@ -0,0 +1,153 @@ +# ========= 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 logging +import re +from pathlib import Path + +from camel.toolkits.skill_toolkit import SkillToolkit as BaseSkillToolkit + +logger = logging.getLogger(__name__) + +SKILL_FILENAME = "SKILL.md" + + +def _skill_roots_for_inject( + working_directory: Path | None = None, +) -> list[Path]: + """Return ordered list of skill root paths (same precedence as SkillToolkit).""" + wd = working_directory if working_directory is not None else Path.cwd() + wd = wd if isinstance(wd, Path) else Path(wd) + roots: list[Path] = [] + roots.append(wd / "skills") + roots.append(wd / ".eigent" / "skills") + roots.append(Path.home() / ".eigent" / "skills") + return roots + + +def _parse_skill_name_from_frontmatter(content: str) -> str | None: + """Extract frontmatter 'name' from SKILL.md content. Returns None if not found.""" + if not content.strip().startswith("---"): + return None + match = re.search(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + if not match: + return None + fm = match.group(1) + name_m = re.search(r"^name:\s*(.+)$", fm, re.MULTILINE) + if not name_m: + return None + return name_m.group(1).strip() + + +def get_skill_content_by_name( + working_directory: str | None = None, + skill_name: str = "", +) -> str | None: + """ + Resolve a skill by display name and return its full SKILL.md content. + Uses the same root order as SkillToolkit (project, .eigent/skills, ~/.eigent/skills). + Returns None if no matching skill is found. + """ + name = (skill_name or "").strip() + if not name: + logger.warning( + "get_skill_content_by_name called with empty skill_name" + ) + return None + + logger.info( + f"🔍 Searching for skill: '{name}' in working_directory: {working_directory}" + ) + name_lower = name.lower() + wd = Path(working_directory) if working_directory else None + + searched_paths = [] + for root in _skill_roots_for_inject(wd): + searched_paths.append(str(root)) + if not root.is_dir(): + logger.debug(f" ⏭️ Skipping non-existent root: {root}") + continue + try: + for entry in root.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_file = entry / SKILL_FILENAME + if not skill_file.is_file(): + continue + raw = skill_file.read_text(encoding="utf-8", errors="replace") + parsed_name = _parse_skill_name_from_frontmatter(raw) + if parsed_name and parsed_name.lower().strip() == name_lower: + logger.info(f"✅ Found skill '{name}' at: {skill_file}") + logger.info(f"📦 Skill size: {len(raw)} characters") + return raw + except OSError as e: + logger.warning(f" ⚠️ Error reading root {root}: {e}") + continue + + logger.warning( + f"❌ Skill '{name}' not found. Searched paths: {searched_paths}" + ) + return None + + +class SkillToolkit(BaseSkillToolkit): + """SkillToolkit that discovers skills with project/worker/global precedence. + + Accepts api_task_id and agent_name (like TerminalToolkit) for logging and + per-agent skill roots. Skill roots: working_directory/skills, + working_directory/.eigent/skills, ~/.eigent/skills, then CAMEL defaults. + """ + + @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, + timeout: float | None = None, + ) -> None: + self.api_task_id = api_task_id + self.agent_name = agent_name + super().__init__( + working_directory=working_directory, + timeout=timeout, + ) + + def _skill_roots(self) -> list[tuple[str, Path]]: + """Return skill roots with Eigent-specific precedence. + + Precedence (highest to lowest) for skills with the same name: + 1. Project-level skills under /skills + 2. Worker/task-local skills under /.eigent/skills + 3. Global user skills under ~/.eigent/skills + 4. Default CAMEL roots (.camel/, .agents/, etc.) + """ + roots: list[tuple[str, Path]] = [] + + # 1) Project-level skills for this task/workspace + roots.append(("repo", self.working_directory / "skills")) + + # 2) Worker/task-local skills under .eigent/skills inside working dir + roots.append(("repo", self.working_directory / ".eigent" / "skills")) + + # 3) Global user-level skills managed by the Eigent app + roots.append(("user", Path.home() / ".eigent" / "skills")) + + # 4) Fall back to CAMEL's default roots (.camel, .agents, etc.) + roots.extend(super()._skill_roots()) + + return roots diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index dce9c8e09..3475aad60 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,11 +309,56 @@ def build_conversation_context( return context -def build_context_for_workforce(task_lock: TaskLock, options: Chat) -> str: - """Build context information for workforce.""" - return build_conversation_context( +# When the user mentions a skill in double curly braces (e.g. {{Data Analyzer}}), +# the coordinator must actively load that skill using tools. +_COORDINATOR_SKILL_INSTRUCTION = """ +🎯 SKILL-BASED TASK DECOMPOSITION: + +When the user mentions a skill in double curly braces (e.g., {{pdf}}, {{data-analyzer}}): + +**YOU MUST FOLLOW THIS WORKFLOW:** + +1. **First, call `list_skills`** to see all available skills and verify the mentioned skill exists + +2. **Then, call `load_skill` with the exact skill name** to retrieve its full content + - The skill content will include code examples, best practices, and detailed instructions + - This is your PRIMARY and AUTHORITATIVE reference for the task + +3. **Design subtasks based on the loaded skill content**: + - Follow the examples, code patterns, and approaches shown in the skill + - Reference specific sections, libraries, and functions mentioned in the skill + - Each subtask should map to specific parts of the skill content + - DO NOT use general knowledge - follow the skill's instructions exactly + +4. **Important**: + - You MUST load the skill before designing subtasks (no shortcuts!) + - The skill name in double braces is just a reference - always load it explicitly + - If `list_skills` shows no matching skill, inform the user + +**Example workflow:** +User: "Use {{pdf}} to extract tables from documents" +You: +- Call `list_skills()` → See available skills including "pdf" +- Call `load_skill("pdf")` → Get full PDF skill content with code examples +- Design subtasks based on the skill's table extraction examples +""" + +# Skills are now loaded explicitly by agents using list_skills/load_skill tools +# rather than being auto-injected based on {{skill}} syntax. + + +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. + """ + base = build_conversation_context( task_lock, header="=== CONVERSATION HISTORY ===" ) + return _COORDINATOR_SKILL_INSTRUCTION.strip() + "\n\n" + base @sync_step @@ -2183,7 +2229,12 @@ 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, + ).get_tools(), ], ) for key, prompt in { 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/pyproject.toml b/backend/pyproject.toml index 8a0c5ccbd..418745de8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11,<3.12" dependencies = [ "pip>=23.0", - "camel-ai[eigent]==0.2.85a0", + "camel-ai[eigent] @ git+https://github.com/camel-ai/camel.git@master", "fastapi>=0.115.12", "fastapi-babel>=1.0.0", "uvicorn[standard]>=0.34.2", diff --git a/backend/uv.lock b/backend/uv.lock index b9c6505a1..1cb57dcb1 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -242,7 +242,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.85a0" }, + { name = "camel-ai", extras = ["eigent"], git = "https://github.com/camel-ai/camel.git?rev=master" }, { name = "debugpy", specifier = ">=1.8.17" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi-babel", specifier = ">=1.0.0" }, @@ -285,8 +285,8 @@ wheels = [ [[package]] name = "camel-ai" -version = "0.2.85a0" -source = { registry = "https://pypi.org/simple" } +version = "0.2.85" +source = { git = "https://github.com/camel-ai/camel.git?rev=master#44b10d08842bee274a831df6b994750241b8a723" } dependencies = [ { name = "astor" }, { name = "colorama" }, @@ -299,13 +299,10 @@ dependencies = [ { name = "pillow" }, { name = "psutil" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "tiktoken" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/ab/7d305f80e868a60c7097ab510063a171e1798d163b5f8fd7fe7c16553e13/camel_ai-0.2.85a0.tar.gz", hash = "sha256:432de9bac1e40bd4ebf434ca80eaf3993121f87924820e26ad2bad69c1fb5cf5", size = 1126159, upload-time = "2026-01-23T02:24:08.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/0c/35d73b5d648413844bdfeaf95172a6b7c19802150829f5f907753a773d19/camel_ai-0.2.85a0-py3-none-any.whl", hash = "sha256:6045e9af72fee918ca3acc92f3b4af8af084af7b0cf6435c01a1252bd04ae6b3", size = 1599866, upload-time = "2026-01-23T02:24:06.78Z" }, -] [package.optional-dependencies] eigent = [ diff --git a/electron-builder.json b/electron-builder.json index a51e9e087..4d3f5135e 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -38,6 +38,10 @@ "!terminal_venv/**/*.pyc", "!terminal_venv/**/__pycache__" ] + }, + { + "from": "resources/example-skills", + "to": "example-skills" } ], "protocols": [ diff --git a/electron/main/index.ts b/electron/main/index.ts index 3cf4facb2..243616e14 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 { @@ -822,6 +823,191 @@ 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 }; + } + }); + + 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 { @@ -1415,6 +1601,7 @@ const ensureEigentDirectories = () => { path.join(eigentBase, 'cache'), path.join(eigentBase, 'venvs'), path.join(eigentBase, 'runtime'), + path.join(eigentBase, 'skills'), ]; for (const dir of requiredDirs) { @@ -1427,6 +1614,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 @@ -1452,6 +1705,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..6b08d8594 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -155,6 +155,20 @@ 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), }); // --------- 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..8bf084546 --- /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..9e0eb6dd2 --- /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/report-writer/SKILL.md b/resources/example-skills/report-writer/SKILL.md new file mode 100644 index 000000000..81c7fb8c9 --- /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/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/lib/skillToolkit.ts b/src/lib/skillToolkit.ts new file mode 100644 index 000000000..f6cd2c431 --- /dev/null +++ b/src/lib/skillToolkit.ts @@ -0,0 +1,132 @@ +// ========= 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; +} { + const lines = contents.split('\n'); + if (!lines.length || lines[0].trim() !== FRONTMATTER_DELIM) { + return { frontmatter: null, body: contents }; + } + 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: contents }; +} + +/** 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(); + out[match[1]] = value.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 front = [ + FRONTMATTER_DELIM, + `name: ${name}`, + `description: ${description}`, + 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/Capabilities/Skills.tsx b/src/pages/Capabilities/Skills.tsx index 8d0d73706..aadf1ba19 100644 --- a/src/pages/Capabilities/Skills.tsx +++ b/src/pages/Capabilities/Skills.tsx @@ -16,7 +16,7 @@ import SearchInput from '@/components/SearchInput'; import { Button } from '@/components/ui/button'; import { useSkillsStore, type Skill } from '@/store/skillsStore'; import { ChevronDown, ChevronUp, Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import SkillDeleteDialog from './components/SkillDeleteDialog'; import SkillListItem from './components/SkillListItem'; @@ -24,7 +24,7 @@ import SkillUploadDialog from './components/SkillUploadDialog'; export default function Skills() { const { t } = useTranslation(); - const { skills } = useSkillsStore(); + const { skills, syncFromDisk } = useSkillsStore(); const [searchQuery, setSearchQuery] = useState(''); const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -32,6 +32,12 @@ export default function Skills() { const [collapsedYourSkills, setCollapsedYourSkills] = useState(false); const [collapsedExampleSkills, setCollapsedExampleSkills] = useState(false); + // 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) diff --git a/src/pages/Capabilities/components/SkillListItem.tsx b/src/pages/Capabilities/components/SkillListItem.tsx index 254044aa1..9c14b9909 100644 --- a/src/pages/Capabilities/components/SkillListItem.tsx +++ b/src/pages/Capabilities/components/SkillListItem.tsx @@ -69,7 +69,7 @@ export default function SkillListItem({ skill, onDelete }: SkillListItemProps) { 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?`; + const prompt = `I just added the {{${skill.name}}} skill for Eigent, can you make something amazing with this skill?`; navigate(`/?skill_prompt=${encodeURIComponent(prompt)}`); }; diff --git a/src/pages/Capabilities/components/SkillUploadDialog.tsx b/src/pages/Capabilities/components/SkillUploadDialog.tsx index a1eeb7a69..e8e0a576a 100644 --- a/src/pages/Capabilities/components/SkillUploadDialog.tsx +++ b/src/pages/Capabilities/components/SkillUploadDialog.tsx @@ -20,6 +20,7 @@ import { DialogFooter, DialogHeader, } from '@/components/ui/dialog'; +import { parseSkillMd } from '@/lib/skillToolkit'; import { useSkillsStore } from '@/store/skillsStore'; import { File, Upload, X } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; @@ -36,12 +37,13 @@ export default function SkillUploadDialog({ onClose, }: SkillUploadDialogProps) { const { t } = useTranslation(); - const { addSkill } = useSkillsStore(); + 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 handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -56,7 +58,7 @@ export default function SkillUploadDialog({ const processFile = useCallback( async (file: File) => { // Validate file type - const validExtensions = ['.skill', '.md', '.txt', '.json']; + const validExtensions = ['.skill', '.md', '.txt', '.json', '.zip']; const extension = file.name .substring(file.name.lastIndexOf('.')) .toLowerCase(); @@ -66,16 +68,23 @@ export default function SkillUploadDialog({ return; } - // Validate file size (max 1MB) - if (file.size > 1024 * 1024) { + // Validate file size (max 5MB to allow small zip bundles) + if (file.size > 5 * 1024 * 1024) { toast.error(t('capabilities.file-too-large')); return; } try { - const content = await file.text(); setSelectedFile(file); - setFileContent(content); + if (extension === '.zip') { + // For zip, we don't read content in renderer; main process will import + setIsZip(true); + setFileContent(''); + } else { + const content = await file.text(); + setIsZip(false); + setFileContent(content); + } } catch (_error) { toast.error(t('capabilities.file-read-error')); } @@ -103,25 +112,48 @@ export default function SkillUploadDialog({ }; const handleUpload = async () => { - if (!selectedFile || !fileContent) return; + if (!selectedFile) return; setIsUploading(true); try { - // Parse skill name from file name or content + // Zip import: read file in renderer and send buffer to main (no path in sandbox) + if (isZip) { + if (!(window as any).electronAPI?.skillImportZip) { + toast.error(t('capabilities.skill-add-error')); + return; + } + let buffer: ArrayBuffer; + try { + buffer = await selectedFile.arrayBuffer(); + } catch { + toast.error(t('capabilities.file-read-error')); + return; + } + const result = await (window as any).electronAPI.skillImportZip(buffer); + if (!result?.success) { + toast.error(result?.error || t('capabilities.skill-add-error')); + return; + } + await syncFromDisk(); + toast.success(t('capabilities.skill-added-success')); + handleClose(); + return; + } + + if (!fileContent) return; + const fileName = selectedFile.name.replace(/\.[^/.]+$/, ''); - // Try to extract name and description from content - let name = fileName; - let description = ''; + // Prefer SKILL.md frontmatter (name + description) at upload time + const meta = parseSkillMd(fileContent); + let name = meta?.name ?? fileName; + let description = meta?.description ?? ''; - // If it's a markdown file, try to parse the first heading and paragraph - if (fileContent.startsWith('#')) { + // Fallback: no frontmatter — use first heading and first paragraph + if (!meta && fileContent.startsWith('#')) { const lines = fileContent.split('\n'); const headingMatch = lines[0].match(/^#\s+(.+)/); - if (headingMatch) { - name = headingMatch[1]; - } - // Find first non-empty, non-heading line for description + if (headingMatch) name = headingMatch[1]; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#')) { @@ -153,6 +185,7 @@ export default function SkillUploadDialog({ setSelectedFile(null); setFileContent(''); setIsDragging(false); + setIsZip(false); onClose(); }; @@ -185,7 +218,7 @@ export default function SkillUploadDialog({ diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts index 0a5640afc..5d68be17d 100644 --- a/src/store/skillsStore.ts +++ b/src/store/skillsStore.ts @@ -12,6 +12,12 @@ // 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'; @@ -28,69 +34,21 @@ export interface Skill { description: string; filePath: string; fileContent: string; + // Optional: folder name under ~/.eigent/skills + skillDirName?: string; addedAt: number; scope: SkillScope; enabled: boolean; isExample: boolean; } -// Example skills -const EXAMPLE_SKILLS: Skill[] = [ - { - id: 'example-web-scraper', - name: 'Web Scraper', - description: - 'Extract data from web pages using CSS selectors or XPath expressions', - filePath: 'examples/web-scraper.skill', - fileContent: `# Web Scraper Skill -This skill allows the agent to scrape web pages and extract structured data. - -## Usage -- Extract text content from specific elements -- Parse tables and lists -- Follow pagination links`, - addedAt: Date.now() - 86400000 * 7, - scope: { isGlobal: true, selectedAgents: [] }, - enabled: true, - isExample: true, - }, - { - id: 'example-file-organizer', - name: 'File Organizer', - description: - 'Automatically organize files into folders based on type, date, or custom rules', - filePath: 'examples/file-organizer.skill', - fileContent: `# File Organizer Skill -This skill helps organize files in directories based on various criteria. - -## Features -- Sort by file type (images, documents, videos) -- Sort by date created/modified -- Custom folder naming rules`, - addedAt: Date.now() - 86400000 * 14, - scope: { isGlobal: true, selectedAgents: [] }, - enabled: true, - isExample: true, - }, - { - id: 'example-data-analyzer', - name: 'Data Analyzer', - description: - 'Analyze CSV and JSON data files to generate insights and visualizations', - filePath: 'examples/data-analyzer.skill', - fileContent: `# Data Analyzer Skill -This skill enables data analysis capabilities for the agent. - -## Capabilities -- Read and parse CSV/JSON files -- Calculate statistics (mean, median, mode) -- Generate summary reports`, - addedAt: Date.now() - 86400000 * 21, - scope: { isGlobal: true, selectedAgents: [] }, - enabled: false, - isExample: true, - }, -]; +// Dir names of default skills seeded by main process under ~/.eigent/skills. +// These are shown in the "Example skills" section; all other disk skills are "Your skills". +export const EXAMPLE_SKILL_DIR_NAMES = [ + 'web-scraper', + 'file-organizer', + 'data-analyzer', +] as const; // Skills state interface interface SkillsState { @@ -100,6 +58,8 @@ interface SkillsState { deleteSkill: (id: string) => void; toggleSkill: (id: string) => void; getSkillsByType: (isExample: boolean) => Skill[]; + // Sync skills from filesystem (Electron) based on SKILL.md files + syncFromDisk: () => Promise; } // Generate unique ID @@ -110,9 +70,29 @@ const generateId = () => export const useSkillsStore = create()( persist( (set, get) => ({ - skills: [...EXAMPLE_SKILLS], + skills: [], addSkill: (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(), @@ -133,6 +113,12 @@ export const useSkillsStore = create()( }, deleteSkill: (id) => { + const current = get().skills.find((s) => s.id === id); + if (current?.skillDirName && hasSkillsFsApi()) { + window.electronAPI.skillDelete(current.skillDirName).catch(() => { + // Ignore deletion errors; state will still be updated + }); + } set((state) => ({ skills: state.skills.filter((skill) => skill.id !== id), })); @@ -149,6 +135,55 @@ export const useSkillsStore = create()( getSkillsByType: (isExample) => { return get().skills.filter((skill) => skill.isExample === isExample); }, + + // Load skills from ~/.eigent/skills (main process seeds example skills when empty) + syncFromDisk: async () => { + if (!hasSkillsFsApi()) return; + try { + const result = await window.electronAPI.skillsScan(); + if (!result.success || !result.skills) return; + + 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); + return { + id: `disk-${s.skillDirName}`, + name: s.name, + description: s.description, + filePath: s.path, + fileContent: existing?.fileContent ?? '', + skillDirName: s.skillDirName, + addedAt: existing?.addedAt ?? Date.now(), + scope: existing?.scope ?? { + isGlobal: true, + selectedAgents: [], + }, + enabled: existing?.enabled ?? (isExample ? true : true), + isExample, + }; + } + ) + .sort((a: Skill, b: Skill) => a.name.localeCompare(b.name)); + + set({ skills: diskSkills }); + } catch { + // Ignore sync errors; keep existing state + } + }, }), { name: 'skills-storage', diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 91af30006..f08000f15 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -134,6 +134,42 @@ 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 }>; } declare global { From b2515ed9cd5ba6bcbe32cf00d3b633e798a2bdfb Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Wed, 11 Feb 2026 17:53:31 +0800 Subject: [PATCH 03/12] WIP skill config by user --- backend/app/agent/toolkit/skill_toolkit.py | 514 ++++++++++++++++++--- backend/app/api/skill_config_api.py | 278 +++++++++++ backend/app/model/chat.py | 2 + backend/app/router.py | 6 + backend/app/service/chat_service.py | 41 +- backend/scripts/init_skills_config.py | 188 ++++++++ electron/main/index.ts | 90 ++++ electron/preload/index.ts | 9 + src/store/skillsStore.ts | 134 +++++- src/types/electron.d.ts | 18 + 10 files changed, 1191 insertions(+), 89 deletions(-) create mode 100644 backend/app/api/skill_config_api.py create mode 100644 backend/scripts/init_skills_config.py diff --git a/backend/app/agent/toolkit/skill_toolkit.py b/backend/app/agent/toolkit/skill_toolkit.py index 2a9f9d68d..4f99270aa 100644 --- a/backend/app/agent/toolkit/skill_toolkit.py +++ b/backend/app/agent/toolkit/skill_toolkit.py @@ -12,32 +12,232 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +""" +Skill Toolkit with two-tier hierarchy: +1. Project-level: /skills and /.eigent/skills +2. Global: ~/.eigent/skills/ + +Priority: Project > Global + +Agent access control is managed via skills-config.json, not by physical separation. +""" + +import json import logging import re +import time +from dataclasses import dataclass from pathlib import Path +from typing import Literal from camel.toolkits.skill_toolkit import SkillToolkit as BaseSkillToolkit logger = logging.getLogger(__name__) SKILL_FILENAME = "SKILL.md" +SKILL_CONFIG_FILENAME = "skills-config.json" + +SkillScope = Literal["global", "project"] + +@dataclass +class SkillMetadata: + """Metadata for a discovered skill.""" -def _skill_roots_for_inject( + name: str + path: Path + scope: SkillScope + + def load_content(self) -> str: + """Lazy load skill content when needed.""" + return self.path.read_text(encoding="utf-8", errors="replace") + + +def _skill_roots_for_discovery( working_directory: Path | None = None, -) -> list[Path]: - """Return ordered list of skill root paths (same precedence as SkillToolkit).""" + agent_name: str | None = None, +) -> list[tuple[SkillScope, Path]]: + """Return ordered list of skill root paths with scope labels. + + Args: + working_directory: Current working directory + agent_name: Unused, kept for backward compatibility + + Returns: + List of (scope, path) tuples in priority order (highest first) + """ wd = working_directory if working_directory is not None else Path.cwd() wd = wd if isinstance(wd, Path) else Path(wd) - roots: list[Path] = [] - roots.append(wd / "skills") - roots.append(wd / ".eigent" / "skills") - roots.append(Path.home() / ".eigent" / "skills") + roots: list[tuple[SkillScope, Path]] = [] + + # 1. Project-level skills (highest priority) + roots.append(("project", wd / "skills")) + roots.append(("project", wd / ".eigent" / "skills")) + + # 2. Global skills (lowest priority) + roots.append(("global", Path.home() / ".eigent" / "skills")) + return roots +class SkillDiscovery: + """Efficient skill discovery with caching and lazy loading.""" + + _cache: dict[str, dict[str, SkillMetadata]] = {} + _cache_timestamp: dict[str, float] = {} + _cache_ttl: float = 60.0 # Cache for 60 seconds + + @classmethod + def _build_cache_key( + cls, + working_directory: Path | None, + agent_name: str | None, + ) -> str: + """Build a unique cache key.""" + wd = str(working_directory) if working_directory else "None" + agent = agent_name or "None" + return f"{wd}::{agent}" + + @classmethod + def _is_cache_valid(cls, cache_key: str) -> bool: + """Check if cache is still valid.""" + if cache_key not in cls._cache_timestamp: + return False + age = time.time() - cls._cache_timestamp[cache_key] + return age < cls._cache_ttl + + @classmethod + def _scan_root( + cls, root: Path, scope: SkillScope + ) -> dict[str, SkillMetadata]: + """Scan a single root directory for skills. + + Supports symbolic links to avoid duplication. + + Returns: + Dict mapping skill names (lowercase) to SkillMetadata + """ + skills: dict[str, SkillMetadata] = {} + + if not root.is_dir(): + return skills + + try: + for entry in root.iterdir(): + # Support symlinks - resolve to actual directory + try: + # Check if it's a directory (following symlinks) + if not entry.is_dir(): + continue + except OSError: + # Broken symlink + logger.debug(f"Skipping broken symlink: {entry}") + continue + + # Skip hidden directories + if entry.name.startswith("."): + continue + + # Follow symlink to find SKILL.md + skill_file = entry / SKILL_FILENAME + try: + # Check if file exists (following symlinks) + if not skill_file.is_file(): + continue + except OSError: + # Broken symlink + logger.debug(f"Skipping broken symlink: {skill_file}") + continue + + # Parse frontmatter to get skill name + try: + content = skill_file.read_text( + encoding="utf-8", errors="replace" + ) + parsed_name = _parse_skill_name_from_frontmatter(content) + if parsed_name: + name_lower = parsed_name.lower().strip() + # Resolve symlink to get the real path + real_path = skill_file.resolve() + skills[name_lower] = SkillMetadata( + name=parsed_name, + path=real_path, # Use resolved path + scope=scope, + ) + + # Log if it's a symlink + if skill_file != real_path: + logger.debug( + f"Found skill '{parsed_name}' via symlink: " + f"{skill_file} -> {real_path}" + ) + except (OSError, UnicodeDecodeError) as e: + logger.debug(f"Failed to read skill at {skill_file}: {e}") + continue + + except OSError as e: + logger.warning(f"Error scanning root {root}: {e}") + + return skills + + @classmethod + def discover( + cls, + working_directory: Path | None = None, + agent_name: str | None = None, + ) -> dict[str, SkillMetadata]: + """Discover all available skills with caching. + + Returns a dict mapping skill names (lowercase) to SkillMetadata. + Higher priority skills override lower priority ones. + + Args: + working_directory: Current working directory + agent_name: Name of the agent requesting skills + + Returns: + Dict of {skill_name_lower: SkillMetadata} + """ + cache_key = cls._build_cache_key(working_directory, agent_name) + + # Return cached result if valid + if cls._is_cache_valid(cache_key): + logger.debug(f"Using cached skill discovery for {cache_key}") + return cls._cache[cache_key] + + # Build skill index from all roots (reverse order for priority) + skills: dict[str, SkillMetadata] = {} + roots = _skill_roots_for_discovery(working_directory, agent_name) + + # Scan in reverse order so higher priority roots override + for scope, root in reversed(roots): + discovered = cls._scan_root(root, scope) + skills.update(discovered) + + # Cache the result + cls._cache[cache_key] = skills + cls._cache_timestamp[cache_key] = time.time() + + logger.debug( + f"Discovered {len(skills)} skills for {cache_key}: " + f"{list(skills.keys())}" + ) + + return skills + + @classmethod + def clear_cache(cls, cache_key: str | None = None) -> None: + """Clear cache for a specific key or all keys.""" + if cache_key: + cls._cache.pop(cache_key, None) + cls._cache_timestamp.pop(cache_key, None) + else: + cls._cache.clear() + cls._cache_timestamp.clear() + + def _parse_skill_name_from_frontmatter(content: str) -> str | None: - """Extract frontmatter 'name' from SKILL.md content. Returns None if not found.""" + """Extract frontmatter 'name' from SKILL.md content.""" if not content.strip().startswith("---"): return None match = re.search(r"^---\s*\n(.*?)\n---", content, re.DOTALL) @@ -50,14 +250,145 @@ def _parse_skill_name_from_frontmatter(content: str) -> str | None: return name_m.group(1).strip() +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] + allowed_agents = skill_config.get("agents", []) + + # Empty list = all agents allowed + if not allowed_agents: + return True + + # Check if current agent is in the allowed list + 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 + + def get_skill_content_by_name( working_directory: str | None = None, skill_name: str = "", + agent_name: str | None = None, + user_id: str | None = None, + check_enabled: bool = True, ) -> str | None: - """ - Resolve a skill by display name and return its full SKILL.md content. - Uses the same root order as SkillToolkit (project, .eigent/skills, ~/.eigent/skills). - Returns None if no matching skill is found. + """Resolve a skill by name and return its SKILL.md content. + + Searches in priority order: + 1. Project-level: /skills, /.eigent/skills + 2. Global: ~/.eigent/skills/ + + Args: + working_directory: Directory to search from + skill_name: Name of the skill to load + agent_name: Name of the agent requesting the skill + user_id: User identifier for loading user-specific config + check_enabled: If True, check if skill is enabled (default: True) + + Returns: + Skill content if found and allowed, None otherwise """ name = (skill_name or "").strip() if not name: @@ -66,47 +397,96 @@ def get_skill_content_by_name( ) return None - logger.info( - f"🔍 Searching for skill: '{name}' in working_directory: {working_directory}" - ) - name_lower = name.lower() wd = Path(working_directory) if working_directory else None - searched_paths = [] - for root in _skill_roots_for_inject(wd): - searched_paths.append(str(root)) - if not root.is_dir(): - logger.debug(f" ⏭️ Skipping non-existent root: {root}") - continue - try: - for entry in root.iterdir(): - if not entry.is_dir() or entry.name.startswith("."): - continue - skill_file = entry / SKILL_FILENAME - if not skill_file.is_file(): - continue - raw = skill_file.read_text(encoding="utf-8", errors="replace") - parsed_name = _parse_skill_name_from_frontmatter(raw) - if parsed_name and parsed_name.lower().strip() == name_lower: - logger.info(f"✅ Found skill '{name}' at: {skill_file}") - logger.info(f"📦 Skill size: {len(raw)} characters") - return raw - except OSError as e: - logger.warning(f" ⚠️ Error reading root {root}: {e}") - continue + # Load config and check permissions + if check_enabled: + config = _get_merged_skill_config(wd, user_id) + + # Check if skill is enabled + if not _is_skill_enabled(name, config): + logger.warning( + f"⚠️ Skill '{name}' is disabled for user '{user_id or 'legacy'}'" + ) + return None + + # Check if agent is allowed to use this skill + if not _is_agent_allowed(name, agent_name, config): + logger.warning( + f"⚠️ Agent '{agent_name}' is not allowed to use skill '{name}'" + ) + return None + + # Discover skills using efficient cached mechanism + skills = SkillDiscovery.discover(wd, agent_name) + name_lower = name.lower() + + # Look up skill in discovered index + metadata = skills.get(name_lower) + if metadata: + logger.info( + f"✅ Found skill '{name}' at {metadata.scope} level: {metadata.path} " + f"(user_id={user_id or 'legacy'})" + ) + content = metadata.load_content() + logger.debug(f"📦 Loaded skill content: {len(content)} characters") + return content logger.warning( - f"❌ Skill '{name}' not found. Searched paths: {searched_paths}" + f"❌ Skill '{name}' not found (user_id={user_id or 'legacy'})" ) return None +def list_available_skills( + working_directory: str | None = None, + agent_name: str | None = None, + user_id: str | None = None, + check_enabled: bool = True, +) -> list[dict[str, str]]: + """List all available skills for an agent. + + Args: + working_directory: Directory to search from + agent_name: Name of the agent requesting skills + user_id: User identifier for loading user-specific config + check_enabled: If True, filter by enabled status and agent permissions + + Returns: + List of dicts with 'name', 'scope', and 'path' keys + """ + wd = Path(working_directory) if working_directory else None + skills = SkillDiscovery.discover(wd, agent_name) + + if check_enabled: + config = _get_merged_skill_config(wd, user_id) + # Filter by enabled status and agent permissions + skills = { + name: meta + for name, meta in skills.items() + if _is_skill_enabled(meta.name, config) + and _is_agent_allowed(meta.name, agent_name, config) + } + + return [ + { + "name": meta.name, + "scope": meta.scope, + "path": str(meta.path), + } + for meta in skills.values() + ] + + class SkillToolkit(BaseSkillToolkit): - """SkillToolkit that discovers skills with project/worker/global precedence. + """SkillToolkit with two-tier hierarchy. - Accepts api_task_id and agent_name (like TerminalToolkit) for logging and - per-agent skill roots. Skill roots: working_directory/skills, - working_directory/.eigent/skills, ~/.eigent/skills, then CAMEL defaults. + Skill Discovery Priority (highest to lowest): + 1. Project-level: /skills and .eigent/skills + 2. Global: ~/.eigent/skills/ + + Agent access control is managed via skills-config.json (agents field). + User isolation is managed via ~/.eigent//skills-config.json. """ @classmethod @@ -118,36 +498,50 @@ def __init__( 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 agent 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-specific precedence. + """Return skill roots with three-tier precedence. - Precedence (highest to lowest) for skills with the same name: - 1. Project-level skills under /skills - 2. Worker/task-local skills under /.eigent/skills - 3. Global user skills under ~/.eigent/skills - 4. Default CAMEL roots (.camel/, .agents/, etc.) + Returns: + List of (label, path) tuples in priority order """ - roots: list[tuple[str, Path]] = [] - - # 1) Project-level skills for this task/workspace - roots.append(("repo", self.working_directory / "skills")) - - # 2) Worker/task-local skills under .eigent/skills inside working dir - roots.append(("repo", self.working_directory / ".eigent" / "skills")) - - # 3) Global user-level skills managed by the Eigent app - roots.append(("user", Path.home() / ".eigent" / "skills")) + # Reuse the centralized discovery function + roots = _skill_roots_for_discovery( + self.working_directory, self.agent_name + ) - # 4) Fall back to CAMEL's default roots (.camel, .agents, etc.) + # Fall back to CAMEL's default roots roots.extend(super()._skill_roots()) return roots + + def clear_cache(self) -> None: + """Clear the skill discovery cache for this toolkit's context.""" + cache_key = SkillDiscovery._build_cache_key( + self.working_directory, self.agent_name + ) + SkillDiscovery.clear_cache(cache_key) + logger.debug(f"Cleared skill cache for {cache_key}") diff --git a/backend/app/api/skill_config_api.py b/backend/app/api/skill_config_api.py new file mode 100644 index 000000000..e21310170 --- /dev/null +++ b/backend/app/api/skill_config_api.py @@ -0,0 +1,278 @@ +# ========= 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. ========= + +"""API endpoints for managing skill configurations (enable/disable).""" + +import json +import logging +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/skills/config", tags=["skills"]) + +SKILL_CONFIG_FILENAME = "skills-config.json" + + +class SkillConfigItem(BaseModel): + """Configuration for a single skill.""" + + enabled: bool + scope: Literal["global", "project"] | None = None + addedAt: int | None = None + isExample: bool | None = None + + +class SkillConfigUpdate(BaseModel): + """Request body for updating skill configuration.""" + + scope: Literal["global", "project"] = "global" + skillName: str + config: SkillConfigItem + + +class SkillToggleRequest(BaseModel): + """Request body for toggling a skill.""" + + enabled: bool + scope: Literal["global", "project"] = "global" + + +def get_config_path( + scope: str, + user_id: str | None = None, + project_path: str | None = None, +) -> Path: + """Get the path to the skill config file based on scope. + + Args: + scope: Configuration scope ('global' or 'project') + user_id: User identifier for user-specific config + project_path: Project path (required for 'project' scope) + + Returns: + Path to config file + """ + if scope == "global": + 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 + elif scope == "project" and project_path: + return Path(project_path) / ".eigent" / SKILL_CONFIG_FILENAME + else: + raise ValueError(f"Invalid scope '{scope}' or missing project_path") + + +def load_config(config_path: Path) -> dict: + """Load skill configuration from JSON file.""" + if not config_path.exists(): + return {"version": 1, "skills": {}} + + try: + with open(config_path, encoding="utf-8") as f: + data = json.load(f) + # Ensure structure + if "version" not in data: + data["version"] = 1 + if "skills" not in data: + data["skills"] = {} + return data + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Failed to load config from {config_path}: {e}") + return {"version": 1, "skills": {}} + + +def save_config(config_path: Path, config: dict) -> None: + """Save skill configuration to JSON file.""" + # Ensure directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + logger.info(f"Saved skill config to {config_path}") + except OSError as e: + logger.error(f"Failed to save config to {config_path}: {e}") + raise HTTPException( + status_code=500, detail=f"Failed to save configuration: {e}" + ) + + +@router.get("/") +async def get_skill_configs( + user_id: str | None = None, project_path: str | None = None +): + """Get both user-global and project skill configurations. + + Args: + user_id: User identifier for loading user-specific config + project_path: Optional path to project for project-level config + """ + user_global_path = get_config_path("global", user_id=user_id) + user_global_config = load_config(user_global_path) + + result = { + "global": user_global_config, + "project": None, + "user_id": user_id, + } + + if project_path: + try: + project_path_obj = ( + Path(project_path) / ".eigent" / SKILL_CONFIG_FILENAME + ) + if project_path_obj.exists(): + result["project"] = load_config(project_path_obj) + except Exception as e: + logger.warning(f"Failed to load project config: {e}") + + return result + + +@router.post("/update") +async def update_skill_config( + update: SkillConfigUpdate, + user_id: str | None = None, + project_path: str | None = None, +): + """Update configuration for a specific skill. + + Args: + update: Configuration update containing scope, skillName, and config + user_id: User identifier for user-specific config + project_path: Required if scope is 'project' + """ + try: + config_path = get_config_path( + update.scope, user_id=user_id, project_path=project_path + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Load existing config + config = load_config(config_path) + + # Update the specific skill + config["skills"][update.skillName] = update.config.model_dump( + exclude_none=True + ) + + # Save back + save_config(config_path, config) + + return { + "success": True, + "message": f"Updated skill '{update.skillName}' in {update.scope} config", + "user_id": user_id, + } + + +@router.post("/{skill_name}/toggle") +async def toggle_skill( + skill_name: str, + toggle: SkillToggleRequest, + user_id: str | None = None, + project_path: str | None = None, +): + """Toggle a skill on or off. + + Args: + skill_name: Name of the skill to toggle + toggle: Toggle request with enabled status and scope + user_id: User identifier for user-specific config + project_path: Required if scope is 'project' + """ + try: + config_path = get_config_path( + toggle.scope, user_id=user_id, project_path=project_path + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Load existing config + config = load_config(config_path) + + # Get existing skill config or create new one + if skill_name in config["skills"]: + skill_config = config["skills"][skill_name] + skill_config["enabled"] = toggle.enabled + else: + # Create new entry + skill_config = { + "enabled": toggle.enabled, + "scope": toggle.scope, + "addedAt": int(__import__("time").time() * 1000), + } + config["skills"][skill_name] = skill_config + + # Save back + save_config(config_path, config) + + return { + "success": True, + "message": f"Skill '{skill_name}' {'enabled' if toggle.enabled else 'disabled'} in {toggle.scope} config", + "skill": skill_config, + "user_id": user_id, + } + + +@router.delete("/{skill_name}") +async def delete_skill_config( + skill_name: str, + scope: Literal["global", "project"] = "global", + user_id: str | None = None, + project_path: str | None = None, +): + """Remove a skill from configuration (revert to default). + + Args: + skill_name: Name of the skill to remove from config + scope: Configuration scope + user_id: User identifier for user-specific config + project_path: Required if scope is 'project' + """ + try: + config_path = get_config_path( + scope, user_id=user_id, project_path=project_path + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Load existing config + config = load_config(config_path) + + # Remove the skill if it exists + if skill_name in config["skills"]: + del config["skills"][skill_name] + save_config(config_path, config) + return { + "success": True, + "message": f"Removed '{skill_name}' from {scope} config", + "user_id": user_id, + } + else: + raise HTTPException( + status_code=404, + detail=f"Skill '{skill_name}' not found in {scope} config", + ) diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index dac358dfd..b1b8f4cfe 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/router.py b/backend/app/router.py index f257dde08..db11e44c6 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -21,6 +21,7 @@ from fastapi import FastAPI +from app.api import skill_config_api from app.controller import ( chat_controller, health_controller, @@ -71,6 +72,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management", }, + { + "router": skill_config_api.router, + "tags": ["skills"], + "description": "Skill configuration management (enable/disable skills)", + }, ] for config in routers_config: diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 3475aad60..36566722c 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -312,35 +312,41 @@ def build_conversation_context( # When the user mentions a skill in double curly braces (e.g. {{Data Analyzer}}), # the coordinator must actively load that skill using tools. _COORDINATOR_SKILL_INSTRUCTION = """ -🎯 SKILL-BASED TASK DECOMPOSITION: +🎯 CRITICAL: SKILL-BASED TASK DECOMPOSITION PROTOCOL -When the user mentions a skill in double curly braces (e.g., {{pdf}}, {{data-analyzer}}): +When the user mentions a skill with double curly braces (e.g., {{pdf}}, {{excalidraw}}, {{data-analyzer}}), +you are REQUIRED to use the SkillToolkit to load that skill. DO NOT access skill files directly. -**YOU MUST FOLLOW THIS WORKFLOW:** +**MANDATORY WORKFLOW (NO EXCEPTIONS):** -1. **First, call `list_skills`** to see all available skills and verify the mentioned skill exists +1. **ALWAYS call `list_skills()` first** + - This shows all available skills + - Verify the mentioned skill exists + - If the skill doesn't exist, inform the user immediately -2. **Then, call `load_skill` with the exact skill name** to retrieve its full content - - The skill content will include code examples, best practices, and detailed instructions - - This is your PRIMARY and AUTHORITATIVE reference for the task +2. **ALWAYS call `load_skill("")` second** + - Pass the exact skill name (lowercase, hyphenated) + - The returned content is the AUTHORITATIVE reference + - Contains examples, patterns, API documentation, and best practices -3. **Design subtasks based on the loaded skill content**: - - Follow the examples, code patterns, and approaches shown in the skill - - Reference specific sections, libraries, and functions mentioned in the skill - - Each subtask should map to specific parts of the skill content - - DO NOT use general knowledge - follow the skill's instructions exactly +3. **Design subtasks based ONLY on the loaded skill content** + - Follow code examples and patterns from the skill + - Reference specific sections, functions, and parameters mentioned + - DO NOT use general knowledge or assumptions + - DO NOT access files like /Users/.../.eigent/skills/.../ directly 4. **Important**: - You MUST load the skill before designing subtasks (no shortcuts!) - The skill name in double braces is just a reference - always load it explicitly - If `list_skills` shows no matching skill, inform the user -**Example workflow:** -User: "Use {{pdf}} to extract tables from documents" +**Example (CORRECT):** +User: "I just added the {{excalidraw}} skill for Eigent, can you make something amazing with this skill?" You: -- Call `list_skills()` → See available skills including "pdf" -- Call `load_skill("pdf")` → Get full PDF skill content with code examples -- Design subtasks based on the skill's table extraction examples +1. Call `list_skills()` → Verify "excalidraw" exists +2. Call `load_skill("excalidraw")` → Get full Excalidraw documentation +3. Design subtasks based on examples in the loaded content + """ # Skills are now loaded explicitly by agents using list_skills/load_skill tools @@ -2234,6 +2240,7 @@ def _create_coordinator_and_task_agents() -> list[ListenChatAgent]: options.project_id, key, working_directory=working_directory, + user_id=options.user_id, ).get_tools(), ], ) 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/electron/main/index.ts b/electron/main/index.ts index 243616e14..b795bade4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -978,6 +978,96 @@ function registerIpcHandlers() { } }); + // ======================== 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); + if (!existsSync(configPath)) { + return { version: 1, skills: {} }; + } + 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]) { + config.skills[skillName] = { + enabled, + scope: 'global', + 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 }; + } + } + ); + ipcMain.handle( 'skill-import-zip', async ( diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6b08d8594..8e4cc893b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -169,6 +169,15 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('skill-import-zip', zipPathOrBuffer), openSkillFolder: (skillName: string) => ipcRenderer.invoke('open-skill-folder', skillName), + // Skills config + 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/src/store/skillsStore.ts b/src/store/skillsStore.ts index 5d68be17d..3ffc02f68 100644 --- a/src/store/skillsStore.ts +++ b/src/store/skillsStore.ts @@ -20,6 +20,17 @@ import { } 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 { @@ -45,18 +56,20 @@ export interface Skill { // Dir names of default skills seeded by main process under ~/.eigent/skills. // These are shown in the "Example skills" section; all other disk skills are "Your skills". export const EXAMPLE_SKILL_DIR_NAMES = [ - 'web-scraper', - 'file-organizer', + 'code-reviewer', + 'report-writer', 'data-analyzer', ] as const; // Skills state interface interface SkillsState { skills: Skill[]; - addSkill: (skill: Omit) => void; + addSkill: ( + skill: Omit + ) => Promise; updateSkill: (id: string, updates: Partial) => void; - deleteSkill: (id: string) => void; - toggleSkill: (id: string) => void; + deleteSkill: (id: string) => Promise; + toggleSkill: (id: string) => Promise; getSkillsByType: (isExample: boolean) => Skill[]; // Sync skills from filesystem (Electron) based on SKILL.md files syncFromDisk: () => Promise; @@ -72,7 +85,7 @@ export const useSkillsStore = create()( (set, get) => ({ skills: [], - addSkill: (skill) => { + addSkill: async (skill) => { // Persist to filesystem (Electron) as CAMEL-compatible SKILL.md if (hasSkillsFsApi()) { const meta = parseSkillMd(skill.fileContent); @@ -99,6 +112,30 @@ export const useSkillsStore = create()( addedAt: Date.now(), isExample: false, }; + + // Update local configuration via Electron IPC + if (hasSkillsFsApi()) { + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (userId) { + const scope = newSkill.scope.isGlobal ? 'global' : 'project'; + await window.electronAPI.skillConfigUpdate( + userId, + newSkill.name, + { + enabled: newSkill.enabled, + 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], })); @@ -112,24 +149,75 @@ export const useSkillsStore = create()( })); }, - deleteSkill: (id) => { + deleteSkill: async (id) => { const current = get().skills.find((s) => s.id === id); - if (current?.skillDirName && hasSkillsFsApi()) { + if (!current) 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: (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((skill) => - skill.id === id ? { ...skill, enabled: !skill.enabled } : skill + 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) => { @@ -140,9 +228,24 @@ export const useSkillsStore = create()( syncFromDisk: async () => { if (!hasSkillsFsApi()) return; try { + // 1. Scan skills from filesystem const result = await window.electronAPI.skillsScan(); if (!result.success || !result.skills) return; + // 2. Load configuration from local file via Electron IPC + let config: any = { global: null, project: null }; + try { + const userId = emailToUserId(useAuthStore.getState().email); + if (userId) { + const result = await window.electronAPI.skillConfigLoad(userId); + if (result.success && result.config) { + config.global = result.config; + } + } + } catch (error) { + console.warn('Failed to load skill config, using defaults:', error); + } + const prevByKey = new Map( get().skills.map((s) => [s.skillDirName ?? s.id, s]) ); @@ -160,6 +263,13 @@ export const useSkillsStore = create()( const isExample = ( EXAMPLE_SKILL_DIR_NAMES as readonly string[] ).includes(s.skillDirName); + + // Get enabled status from config (project overrides global) + const globalConfig = config.global?.skills?.[s.name]; + const projectConfig = config.project?.skills?.[s.name]; + const enabledFromConfig = + projectConfig?.enabled ?? globalConfig?.enabled ?? true; + return { id: `disk-${s.skillDirName}`, name: s.name, @@ -172,7 +282,7 @@ export const useSkillsStore = create()( isGlobal: true, selectedAgents: [], }, - enabled: existing?.enabled ?? (isExample ? true : true), + enabled: enabledFromConfig, isExample, }; } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f08000f15..f4fe0e3e0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -170,6 +170,24 @@ interface ElectronAPI { openSkillFolder: ( skillName: string ) => Promise<{ success: boolean; error?: string }>; + // Skills config (~/.eigent//skills-config.json) + 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 { From e63538ea1cf04081932426bc9129b367f4280599 Mon Sep 17 00:00:00 2001 From: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:10:09 +0100 Subject: [PATCH 04/12] Update skill ui (#1252) Co-authored-by: 4pmtong --- src/components/Navigation/index.tsx | 4 +- src/components/SearchInput/index.tsx | 136 ++++++- src/components/WorkFlow/agents.tsx | 106 +++++ src/components/WorkFlow/node.tsx | 54 +-- src/components/ui/alertDialog.tsx | 7 +- src/components/ui/dialog.tsx | 20 +- src/components/ui/tabs.tsx | 200 ++++++++-- src/components/ui/toggle-group.tsx | 1 + .../ar/{capabilities.json => agents.json} | 5 + src/i18n/locales/ar/index.ts | 4 +- .../de/{capabilities.json => agents.json} | 5 + src/i18n/locales/de/index.ts | 4 +- .../en-us/{capabilities.json => agents.json} | 5 + src/i18n/locales/en-us/index.ts | 4 +- src/i18n/locales/en-us/setting.json | 3 +- .../es/{capabilities.json => agents.json} | 5 + src/i18n/locales/es/index.ts | 4 +- .../fr/{capabilities.json => agents.json} | 5 + src/i18n/locales/fr/index.ts | 4 +- .../it/{capabilities.json => agents.json} | 5 + src/i18n/locales/it/index.ts | 4 +- .../ja/{capabilities.json => agents.json} | 5 + src/i18n/locales/ja/index.ts | 4 +- .../ko/{capabilities.json => agents.json} | 5 + src/i18n/locales/ko/index.ts | 4 +- .../ru/{capabilities.json => agents.json} | 5 + src/i18n/locales/ru/index.ts | 4 +- .../{capabilities.json => agents.json} | 5 + src/i18n/locales/zh-Hans/index.ts | 4 +- .../{capabilities.json => agents.json} | 5 + src/i18n/locales/zh-Hant/index.ts | 4 +- src/lib/skillToolkit.ts | 11 +- src/pages/{Capabilities => Agents}/Memory.tsx | 28 +- src/pages/{Setting => Agents}/Models.tsx | 0 src/pages/Agents/Skills.tsx | 192 +++++++++ .../components/SkillDeleteDialog.tsx | 43 +- src/pages/Agents/components/SkillListItem.tsx | 278 +++++++++++++ .../Agents/components/SkillUploadDialog.tsx | 371 ++++++++++++++++++ src/pages/{Capabilities => Agents}/index.tsx | 18 +- src/pages/Capabilities/Skills.tsx | 220 ----------- .../Capabilities/components/SkillListItem.tsx | 136 ------- .../components/SkillScopeSelect.tsx | 181 --------- .../components/SkillUploadDialog.tsx | 298 -------------- src/pages/History.tsx | 15 +- src/pages/Home.tsx | 2 +- src/pages/Setting.tsx | 10 +- 46 files changed, 1407 insertions(+), 1026 deletions(-) create mode 100644 src/components/WorkFlow/agents.tsx rename src/i18n/locales/ar/{capabilities.json => agents.json} (89%) rename src/i18n/locales/de/{capabilities.json => agents.json} (89%) rename src/i18n/locales/en-us/{capabilities.json => agents.json} (87%) rename src/i18n/locales/es/{capabilities.json => agents.json} (88%) rename src/i18n/locales/fr/{capabilities.json => agents.json} (88%) rename src/i18n/locales/it/{capabilities.json => agents.json} (88%) rename src/i18n/locales/ja/{capabilities.json => agents.json} (87%) rename src/i18n/locales/ko/{capabilities.json => agents.json} (87%) rename src/i18n/locales/ru/{capabilities.json => agents.json} (88%) rename src/i18n/locales/zh-Hans/{capabilities.json => agents.json} (87%) rename src/i18n/locales/zh-Hant/{capabilities.json => agents.json} (87%) rename src/pages/{Capabilities => Agents}/Memory.tsx (56%) rename src/pages/{Setting => Agents}/Models.tsx (100%) create mode 100644 src/pages/Agents/Skills.tsx rename src/pages/{Capabilities => Agents}/components/SkillDeleteDialog.tsx (57%) create mode 100644 src/pages/Agents/components/SkillListItem.tsx create mode 100644 src/pages/Agents/components/SkillUploadDialog.tsx rename src/pages/{Capabilities => Agents}/index.tsx (78%) delete mode 100644 src/pages/Capabilities/Skills.tsx delete mode 100644 src/pages/Capabilities/components/SkillListItem.tsx delete mode 100644 src/pages/Capabilities/components/SkillScopeSelect.tsx delete mode 100644 src/pages/Capabilities/components/SkillUploadDialog.tsx 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 ad13607cb..b74f60758 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -12,29 +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; } +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/capabilities.json b/src/i18n/locales/ar/agents.json similarity index 89% rename from src/i18n/locales/ar/capabilities.json rename to src/i18n/locales/ar/agents.json index cf201b3f1..521624d12 100644 --- a/src/i18n/locales/ar/capabilities.json +++ b/src/i18n/locales/ar/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "ستتيح ميزات الذاكرة لوكلائك تذكر المعلومات المهمة عبر الجلسات.", "learn-more": "معرفة المزيد", "search-skills": "البحث عن المهارات...", + "search-tooltip": "بحث", + "clear-search-tooltip": "مسح البحث", "add": "إضافة", "your-skills": "مهاراتك", "example-skills": "مهارات نموذجية", @@ -36,6 +38,9 @@ "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": "مهارة مخصصة", diff --git a/src/i18n/locales/ar/index.ts b/src/i18n/locales/ar/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/ar/index.ts +++ b/src/i18n/locales/ar/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/de/capabilities.json b/src/i18n/locales/de/agents.json similarity index 89% rename from src/i18n/locales/de/capabilities.json rename to src/i18n/locales/de/agents.json index b480f3379..18bc2dd25 100644 --- a/src/i18n/locales/de/capabilities.json +++ b/src/i18n/locales/de/agents.json @@ -7,6 +7,8 @@ "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", @@ -36,6 +38,9 @@ "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", diff --git a/src/i18n/locales/de/index.ts b/src/i18n/locales/de/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/de/index.ts +++ b/src/i18n/locales/de/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/en-us/capabilities.json b/src/i18n/locales/en-us/agents.json similarity index 87% rename from src/i18n/locales/en-us/capabilities.json rename to src/i18n/locales/en-us/agents.json index 847bb135b..13f215331 100644 --- a/src/i18n/locales/en-us/capabilities.json +++ b/src/i18n/locales/en-us/agents.json @@ -7,6 +7,8 @@ "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", @@ -36,6 +38,9 @@ "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", diff --git a/src/i18n/locales/en-us/index.ts b/src/i18n/locales/en-us/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/en-us/index.ts +++ b/src/i18n/locales/en-us/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, 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/capabilities.json b/src/i18n/locales/es/agents.json similarity index 88% rename from src/i18n/locales/es/capabilities.json rename to src/i18n/locales/es/agents.json index 35b6f330b..c16ad29b1 100644 --- a/src/i18n/locales/es/capabilities.json +++ b/src/i18n/locales/es/agents.json @@ -7,6 +7,8 @@ "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", @@ -36,6 +38,9 @@ "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", diff --git a/src/i18n/locales/es/index.ts b/src/i18n/locales/es/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/es/index.ts +++ b/src/i18n/locales/es/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/fr/capabilities.json b/src/i18n/locales/fr/agents.json similarity index 88% rename from src/i18n/locales/fr/capabilities.json rename to src/i18n/locales/fr/agents.json index 3c046d46c..45a457600 100644 --- a/src/i18n/locales/fr/capabilities.json +++ b/src/i18n/locales/fr/agents.json @@ -7,6 +7,8 @@ "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", @@ -36,6 +38,9 @@ "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", diff --git a/src/i18n/locales/fr/index.ts b/src/i18n/locales/fr/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/fr/index.ts +++ b/src/i18n/locales/fr/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/it/capabilities.json b/src/i18n/locales/it/agents.json similarity index 88% rename from src/i18n/locales/it/capabilities.json rename to src/i18n/locales/it/agents.json index 0e8405f2f..b9855f585 100644 --- a/src/i18n/locales/it/capabilities.json +++ b/src/i18n/locales/it/agents.json @@ -7,6 +7,8 @@ "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", @@ -36,6 +38,9 @@ "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", diff --git a/src/i18n/locales/it/index.ts b/src/i18n/locales/it/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/it/index.ts +++ b/src/i18n/locales/it/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ja/capabilities.json b/src/i18n/locales/ja/agents.json similarity index 87% rename from src/i18n/locales/ja/capabilities.json rename to src/i18n/locales/ja/agents.json index 6d40bc094..eef61ac5a 100644 --- a/src/i18n/locales/ja/capabilities.json +++ b/src/i18n/locales/ja/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "メモリ機能により、エージェントがセッション間で重要な情報を記憶できるようになります。", "learn-more": "詳細を見る", "search-skills": "スキルを検索...", + "search-tooltip": "検索", + "clear-search-tooltip": "検索をクリア", "add": "追加", "your-skills": "あなたのスキル", "example-skills": "サンプルスキル", @@ -36,6 +38,9 @@ "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": "カスタムスキル", diff --git a/src/i18n/locales/ja/index.ts b/src/i18n/locales/ja/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/ja/index.ts +++ b/src/i18n/locales/ja/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ko/capabilities.json b/src/i18n/locales/ko/agents.json similarity index 87% rename from src/i18n/locales/ko/capabilities.json rename to src/i18n/locales/ko/agents.json index faca71e95..5649fb6d1 100644 --- a/src/i18n/locales/ko/capabilities.json +++ b/src/i18n/locales/ko/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "메모리 기능을 통해 에이전트가 세션 간에 중요한 정보를 기억할 수 있습니다.", "learn-more": "자세히 알아보기", "search-skills": "스킬 검색...", + "search-tooltip": "검색", + "clear-search-tooltip": "검색 지우기", "add": "추가", "your-skills": "내 스킬", "example-skills": "예제 스킬", @@ -36,6 +38,9 @@ "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": "사용자 정의 스킬", diff --git a/src/i18n/locales/ko/index.ts b/src/i18n/locales/ko/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/ko/index.ts +++ b/src/i18n/locales/ko/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/ru/capabilities.json b/src/i18n/locales/ru/agents.json similarity index 88% rename from src/i18n/locales/ru/capabilities.json rename to src/i18n/locales/ru/agents.json index 8ea9de71d..ef7c027f3 100644 --- a/src/i18n/locales/ru/capabilities.json +++ b/src/i18n/locales/ru/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "Функции памяти позволят вашим агентам запоминать важную информацию между сеансами.", "learn-more": "Узнать больше", "search-skills": "Поиск навыков...", + "search-tooltip": "Поиск", + "clear-search-tooltip": "Очистить поиск", "add": "Добавить", "your-skills": "Ваши навыки", "example-skills": "Примеры навыков", @@ -36,6 +38,9 @@ "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": "Пользовательский навык", diff --git a/src/i18n/locales/ru/index.ts b/src/i18n/locales/ru/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/ru/index.ts +++ b/src/i18n/locales/ru/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hans/capabilities.json b/src/i18n/locales/zh-Hans/agents.json similarity index 87% rename from src/i18n/locales/zh-Hans/capabilities.json rename to src/i18n/locales/zh-Hans/agents.json index 81099d716..adedc1988 100644 --- a/src/i18n/locales/zh-Hans/capabilities.json +++ b/src/i18n/locales/zh-Hans/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "记忆功能将允许您的智能体在会话之间记住重要信息。", "learn-more": "了解更多", "search-skills": "搜索技能...", + "search-tooltip": "搜索", + "clear-search-tooltip": "清除搜索", "add": "添加", "your-skills": "您的技能", "example-skills": "示例技能", @@ -36,6 +38,9 @@ "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": "自定义技能", diff --git a/src/i18n/locales/zh-Hans/index.ts b/src/i18n/locales/zh-Hans/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/zh-Hans/index.ts +++ b/src/i18n/locales/zh-Hans/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/i18n/locales/zh-Hant/capabilities.json b/src/i18n/locales/zh-Hant/agents.json similarity index 87% rename from src/i18n/locales/zh-Hant/capabilities.json rename to src/i18n/locales/zh-Hant/agents.json index dff4182b5..2884243db 100644 --- a/src/i18n/locales/zh-Hant/capabilities.json +++ b/src/i18n/locales/zh-Hant/agents.json @@ -7,6 +7,8 @@ "memory-coming-soon-description": "記憶功能將允許您的智能體在會話之間記住重要資訊。", "learn-more": "了解更多", "search-skills": "搜尋技能...", + "search-tooltip": "搜尋", + "clear-search-tooltip": "清除搜尋", "add": "新增", "your-skills": "您的技能", "example-skills": "範例技能", @@ -36,6 +38,9 @@ "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": "自訂技能", diff --git a/src/i18n/locales/zh-Hant/index.ts b/src/i18n/locales/zh-Hant/index.ts index f00c65d93..dc2a149a6 100644 --- a/src/i18n/locales/zh-Hant/index.ts +++ b/src/i18n/locales/zh-Hant/index.ts @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import capabilities from './capabilities.json'; +import agents from './agents.json'; import chat from './chat.json'; import dashboard from './dashboard.json'; import layout from './layout.json'; @@ -20,7 +20,7 @@ import setting from './setting.json'; import update from './update.json'; import workforce from './workforce.json'; export default { - capabilities, + agents, layout, dashboard, workforce, diff --git a/src/lib/skillToolkit.ts b/src/lib/skillToolkit.ts index f6cd2c431..3f4ef7896 100644 --- a/src/lib/skillToolkit.ts +++ b/src/lib/skillToolkit.ts @@ -46,9 +46,11 @@ export function splitFrontmatter(contents: string): { frontmatter: string | null; body: string; } { - const lines = contents.split('\n'); + // 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: contents }; + return { frontmatter: null, body: cleaned }; } for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === FRONTMATTER_DELIM) { @@ -57,7 +59,7 @@ export function splitFrontmatter(contents: string): { return { frontmatter, body }; } } - return { frontmatter: null, body: contents }; + return { frontmatter: null, body: cleaned }; } /** Simple YAML-like parse for "name:" and "description:" (first-level keys only). */ @@ -68,7 +70,8 @@ function parseSimpleYaml(text: string): Record { const match = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/); if (match) { const value = match[2].trim(); - out[match[1]] = value.replace(/^['"]|['"]$/g, '').trim(); + // Lowercase key so `Name:` / `name:` / `NAME:` all work + out[match[1].toLowerCase()] = value.replace(/^['"]|['"]$/g, '').trim(); } } return out; diff --git a/src/pages/Capabilities/Memory.tsx b/src/pages/Agents/Memory.tsx similarity index 56% rename from src/pages/Capabilities/Memory.tsx rename to src/pages/Agents/Memory.tsx index 2451d606f..2647c67c3 100644 --- a/src/pages/Capabilities/Memory.tsx +++ b/src/pages/Agents/Memory.tsx @@ -19,25 +19,27 @@ export default function Memory() { const { t } = useTranslation(); return ( -
+
{/* Header Section */} -
+
- {t('capabilities.memory')} + {t('agents.memory')}
- {/* Coming Soon Card */} -
-
- + {/* Content Section */} +
+
+
+ +
+

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

+

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

-

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

-

- {t('capabilities.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..1f982a2a2 --- /dev/null +++ b/src/pages/Agents/Skills.tsx @@ -0,0 +1,192 @@ +// ========= 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')} + + + {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) => ( + handleDeleteClick(skill)} + /> + ))} +
+ )} +
+
+
+
+ + {/* Upload Dialog */} + setUploadDialogOpen(false)} + /> + + {/* Delete Dialog */} + +
+ ); +} diff --git a/src/pages/Capabilities/components/SkillDeleteDialog.tsx b/src/pages/Agents/components/SkillDeleteDialog.tsx similarity index 57% rename from src/pages/Capabilities/components/SkillDeleteDialog.tsx rename to src/pages/Agents/components/SkillDeleteDialog.tsx index 19c397642..1aec0f88d 100644 --- a/src/pages/Capabilities/components/SkillDeleteDialog.tsx +++ b/src/pages/Agents/components/SkillDeleteDialog.tsx @@ -12,13 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { - Dialog, - DialogContent, - DialogContentSection, - DialogFooter, - DialogHeader, -} from '@/components/ui/dialog'; +import ConfirmModal from '@/components/ui/alertDialog'; import { useSkillsStore, type Skill } from '@/store/skillsStore'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -42,32 +36,23 @@ export default function SkillDeleteDialog({ const handleDelete = () => { if (skill) { deleteSkill(skill.id); - toast.success(t('capabilities.skill-deleted-success')); + toast.success(t('agents.skill-deleted-success')); } onConfirm(); }; return ( - !isOpen && onCancel()}> - - - -

- {t('capabilities.delete-skill-confirmation', { - name: skill?.name || '', - })} -

-
- -
-
+ ); } diff --git a/src/pages/Agents/components/SkillListItem.tsx b/src/pages/Agents/components/SkillListItem.tsx new file mode 100644 index 000000000..d44467419 --- /dev/null +++ b/src/pages/Agents/components/SkillListItem.tsx @@ -0,0 +1,278 @@ +// ========= 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 { + 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", select every available agent + handleScopeChange({ + isGlobal: true, + selectedAgents: allAgents, + }); + } + }; + + const handleToggleAgent = (agentName: string) => { + 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')} + + + + {t('layout.delete')} + + + +
+
+ + {/* Row 2: Description full width / wrapped */} +
+

+ {skill.description} +

+
+ + {/* Row 3: Added time / Skill scope */} +
+ + + {scopeOpen && ( +
+ {/* All agents as first tab; then each agent toggle */} + + + {allAgents.map((agentName) => { + const isSelected = 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/Capabilities/index.tsx b/src/pages/Agents/index.tsx similarity index 78% rename from src/pages/Capabilities/index.tsx rename to src/pages/Agents/index.tsx index e2db2bc11..d8543f9df 100644 --- a/src/pages/Capabilities/index.tsx +++ b/src/pages/Agents/index.tsx @@ -15,10 +15,10 @@ import VerticalNavigation, { type VerticalNavItem, } from '@/components/Navigation'; -import Models from '@/pages/Setting/Models'; 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() { @@ -32,11 +32,11 @@ export default function Capabilities() { }, { id: 'skills', - name: t('capabilities.skills'), + name: t('agents.skills'), }, { id: 'memory', - name: t('capabilities.memory'), + name: t('agents.memory'), }, ]; @@ -45,17 +45,15 @@ export default function Capabilities() { }; return ( -
-
-
+
+
+
({ value: menu.id, label: ( - - {menu.name} - + {menu.name} ), })) as VerticalNavItem[] } @@ -68,7 +66,7 @@ export default function Capabilities() {
-
+
{activeTab === 'models' && } {activeTab === 'skills' && } {activeTab === 'memory' && } diff --git a/src/pages/Capabilities/Skills.tsx b/src/pages/Capabilities/Skills.tsx deleted file mode 100644 index aadf1ba19..000000000 --- a/src/pages/Capabilities/Skills.tsx +++ /dev/null @@ -1,220 +0,0 @@ -// ========= 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 { useSkillsStore, type Skill } from '@/store/skillsStore'; -import { ChevronDown, ChevronUp, 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); - const [collapsedYourSkills, setCollapsedYourSkills] = useState(false); - const [collapsedExampleSkills, setCollapsedExampleSkills] = useState(false); - - // 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('capabilities.skills')} -
-
- setSearchQuery(e.target.value)} - placeholder={t('capabilities.search-skills')} - /> - -
-
- - {/* Your Skills Section */} -
-
- - {t('capabilities.your-skills')} - - -
- {!collapsedYourSkills && ( - <> - {yourSkills.length === 0 ? ( -
-

- {searchQuery - ? t('capabilities.no-skills-found') - : t('capabilities.no-your-skills')} -

- {!searchQuery && ( - - )} -
- ) : ( -
- {yourSkills.map((skill) => ( - handleDeleteClick(skill)} - /> - ))} -
- )} - - )} -
- - {/* Example Skills Section */} -
-
- - {t('capabilities.example-skills')} - - -
- {!collapsedExampleSkills && ( - <> - {exampleSkills.length === 0 ? ( -
-

- {searchQuery - ? t('capabilities.no-skills-found') - : t('capabilities.no-example-skills')} -

-
- ) : ( -
- {exampleSkills.map((skill) => ( - handleDeleteClick(skill)} - /> - ))} -
- )} - - )} -
- - {/* Upload Dialog */} - setUploadDialogOpen(false)} - /> - - {/* Delete Dialog */} - -
- ); -} diff --git a/src/pages/Capabilities/components/SkillListItem.tsx b/src/pages/Capabilities/components/SkillListItem.tsx deleted file mode 100644 index 9c14b9909..000000000 --- a/src/pages/Capabilities/components/SkillListItem.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// ========= 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 { Switch } from '@/components/ui/switch'; -import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; -import { useSkillsStore, type Skill } from '@/store/skillsStore'; -import { Ellipsis, MessageSquare, Trash2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import SkillScopeSelect from './SkillScopeSelect'; - -interface SkillListItemProps { - skill: Skill; - onDelete: () => void; -} - -export default function SkillListItem({ skill, onDelete }: SkillListItemProps) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { toggleSkill, updateSkill } = useSkillsStore(); - const { projectStore } = useChatStoreAdapter(); - - 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 handleToggle = () => { - toggleSkill(skill.id); - }; - - const handleScopeChange = (scope: { - isGlobal: boolean; - selectedAgents: string[]; - }) => { - updateSkill(skill.id, { scope }); - }; - - 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 ( -
- {/* Left side: Status dot + Info */} -
- {/* Status indicator dot */} -
- {/* Name and description */} -
- - {skill.name} - -

- {skill.description} -

- - {t('capabilities.added')} {formatDate(skill.addedAt)} - -
-
- - {/* Right side: Controls */} -
- {/* Scope Select */} - - - {/* Enable/Disable Switch */} - - - {/* More Actions Menu (三个点) */} - - - - - - - - {t('capabilities.try-in-chat')} - - - - {t('layout.delete')} - - - -
-
- ); -} diff --git a/src/pages/Capabilities/components/SkillScopeSelect.tsx b/src/pages/Capabilities/components/SkillScopeSelect.tsx deleted file mode 100644 index ea32bc1ed..000000000 --- a/src/pages/Capabilities/components/SkillScopeSelect.tsx +++ /dev/null @@ -1,181 +0,0 @@ -// ========= 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 { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { useWorkerList } from '@/store/authStore'; -import type { SkillScope } from '@/store/skillsStore'; -import { Check, ChevronDown, Globe, Users } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -// Default agent types that are always available -const DEFAULT_AGENTS = [ - 'Developer Agent', - 'Browser Agent', - 'Multi-modal Agent', - 'Document Agent', -]; - -// Special identifier for Global option -const GLOBAL_OPTION = '__GLOBAL__'; - -interface SkillScopeSelectProps { - scope: SkillScope; - onChange: (scope: SkillScope) => void; - disabled?: boolean; -} - -export default function SkillScopeSelect({ - scope, - onChange, - disabled = false, -}: SkillScopeSelectProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const workerList = useWorkerList(); - - // Combine default agents with user-configured workers - // New workers will automatically appear since workerList is reactive - const allAgents = useMemo(() => { - const workerNames = workerList.map((w) => w.name); - // Combine default agents with workers, avoiding duplicates - const combined = [...DEFAULT_AGENTS]; - workerNames.forEach((name) => { - if (!combined.includes(name)) { - combined.push(name); - } - }); - return combined; - }, [workerList]); - - // Handle toggle for any option (including Global) - const handleToggle = (optionName: string) => { - if (optionName === GLOBAL_OPTION) { - // Toggle Global - onChange({ - isGlobal: !scope.isGlobal, - selectedAgents: scope.selectedAgents, - }); - } else { - // Toggle agent - const isSelected = scope.selectedAgents.includes(optionName); - let newSelectedAgents: string[]; - - if (isSelected) { - newSelectedAgents = scope.selectedAgents.filter( - (a) => a !== optionName - ); - } else { - newSelectedAgents = [...scope.selectedAgents, optionName]; - } - - onChange({ - isGlobal: scope.isGlobal, - selectedAgents: newSelectedAgents, - }); - } - }; - - const getDisplayText = () => { - const selections: string[] = []; - - if (scope.isGlobal) { - selections.push(t('capabilities.global')); - } - - selections.push(...scope.selectedAgents); - - if (selections.length === 0) { - return t('capabilities.select-scope'); - } - if (selections.length === 1) { - return selections[0]; - } - return `${selections.length} ${t('capabilities.selected')}`; - }; - - const hasSelection = scope.isGlobal || scope.selectedAgents.length > 0; - - return ( -
- - {t('capabilities.skill-scope')} - - - - - - -
- {/* Global Option - same level as agents */} - - - {/* Agent/Worker List - Multi-select, same level as Global */} - {allAgents.map((agentName) => { - const isSelected = scope.selectedAgents.includes(agentName); - return ( - - ); - })} -
-
-
-
- ); -} diff --git a/src/pages/Capabilities/components/SkillUploadDialog.tsx b/src/pages/Capabilities/components/SkillUploadDialog.tsx deleted file mode 100644 index e8e0a576a..000000000 --- a/src/pages/Capabilities/components/SkillUploadDialog.tsx +++ /dev/null @@ -1,298 +0,0 @@ -// ========= 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, - DialogFooter, - DialogHeader, -} from '@/components/ui/dialog'; -import { parseSkillMd } from '@/lib/skillToolkit'; -import { useSkillsStore } from '@/store/skillsStore'; -import { 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 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) => { - // Validate file type - const validExtensions = ['.skill', '.md', '.txt', '.json', '.zip']; - const extension = file.name - .substring(file.name.lastIndexOf('.')) - .toLowerCase(); - - if (!validExtensions.includes(extension)) { - toast.error(t('capabilities.invalid-file-type')); - return; - } - - // Validate file size (max 5MB to allow small zip bundles) - if (file.size > 5 * 1024 * 1024) { - toast.error(t('capabilities.file-too-large')); - return; - } - - try { - setSelectedFile(file); - if (extension === '.zip') { - // For zip, we don't read content in renderer; main process will import - setIsZip(true); - setFileContent(''); - } else { - const content = await file.text(); - setIsZip(false); - setFileContent(content); - } - } catch (_error) { - toast.error(t('capabilities.file-read-error')); - } - }, - [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 handleUpload = async () => { - if (!selectedFile) return; - - setIsUploading(true); - try { - // Zip import: read file in renderer and send buffer to main (no path in sandbox) - if (isZip) { - if (!(window as any).electronAPI?.skillImportZip) { - toast.error(t('capabilities.skill-add-error')); - return; - } - let buffer: ArrayBuffer; - try { - buffer = await selectedFile.arrayBuffer(); - } catch { - toast.error(t('capabilities.file-read-error')); - return; - } - const result = await (window as any).electronAPI.skillImportZip(buffer); - if (!result?.success) { - toast.error(result?.error || t('capabilities.skill-add-error')); - return; - } - await syncFromDisk(); - toast.success(t('capabilities.skill-added-success')); - handleClose(); - return; - } - - if (!fileContent) return; - - const fileName = selectedFile.name.replace(/\.[^/.]+$/, ''); - - // Prefer SKILL.md frontmatter (name + description) at upload time - const meta = parseSkillMd(fileContent); - let name = meta?.name ?? fileName; - let description = meta?.description ?? ''; - - // Fallback: no frontmatter — use first heading and first paragraph - if (!meta && fileContent.startsWith('#')) { - const lines = fileContent.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('capabilities.custom-skill'), - filePath: selectedFile.name, - fileContent, - scope: { isGlobal: true, selectedAgents: [] }, - enabled: true, - }); - - toast.success(t('capabilities.skill-added-success')); - handleClose(); - } catch (_error) { - toast.error(t('capabilities.skill-add-error')); - } finally { - setIsUploading(false); - } - }; - - const handleClose = () => { - setSelectedFile(null); - setFileContent(''); - setIsDragging(false); - setIsZip(false); - onClose(); - }; - - const handleRemoveFile = () => { - setSelectedFile(null); - setFileContent(''); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - return ( - !isOpen && handleClose()}> - - - -
- {/* Drop Zone */} -
fileInputRef.current?.click()} - > - - - {selectedFile ? ( -
-
- -
-
-

- {selectedFile.name} -

-

- {(selectedFile.size / 1024).toFixed(1)} KB -

-
- -
- ) : ( -
-
- -
-
-

- {t('capabilities.drag-and-drop')} -

-

- {t('capabilities.or-click-to-browse')} -

-
-
- )} -
- - {/* File Requirements */} -
-

- {t('capabilities.file-requirements')} -

-
    -
  • - - {t('capabilities.file-requirements-detail-1')} -
  • -
  • - - {t('capabilities.file-requirements-detail-2')} -
  • -
-
-
-
- -
-
- ); -} diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 934e8f2d5..c2786b0cf 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'; @@ -27,11 +28,11 @@ import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import Project from '@/pages/Dashboard/Project'; import Setting from '@/pages/Setting'; import { useAuthStore } from '@/store/authStore'; -import { Layers, Plus } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import Capabilities from './Capabilities'; +import Agents from './Agents'; import Browser from './Dashboard/Browser'; import MCP from './Setting/MCP'; @@ -42,7 +43,7 @@ const VALID_TABS = [ 'settings', 'mcp_tools', 'browser', - 'capabilities', + 'agents', ] as const; type TabType = (typeof VALID_TABS)[number]; @@ -172,11 +173,11 @@ export default function Home() { } + icon={} > - {t('layout.capabilities')} + {t('setting.agents')} } {activeTab === 'browser' && } {activeTab === 'settings' && } - {activeTab === 'capabilities' && } + {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' && }
From 578019980bbc24adf800d8c8358b2ef107ac218c Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 14 Feb 2026 01:05:34 +0800 Subject: [PATCH 05/12] add config for skills --- backend/app/agent/factory/social_media.py | 11 +- backend/app/agent/toolkit/skill_toolkit.py | 502 +++++------------- backend/app/service/chat_service.py | 96 +--- electron/main/index.ts | 93 +++- electron/preload/index.ts | 3 +- resources/example-skills/default-config.json | 33 ++ src/pages/Agents/components/SkillListItem.tsx | 33 +- src/store/skillsStore.ts | 102 +++- src/types/electron.d.ts | 4 +- 9 files changed, 403 insertions(+), 474 deletions(-) create mode 100644 resources/example-skills/default-config.json diff --git a/backend/app/agent/factory/social_media.py b/backend/app/agent/factory/social_media.py index 649613bb6..19ab7ed08 100644 --- a/backend/app/agent/factory/social_media.py +++ b/backend/app/agent/factory/social_media.py @@ -11,8 +11,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from camel.messages import BaseMessage - from app.agent.agent_model import agent_model from app.agent.listen_chat_agent import logger from app.agent.prompt import SOCIAL_MEDIA_SYS_PROMPT @@ -20,11 +18,11 @@ from app.agent.toolkit.google_gmail_mcp_toolkit import GoogleGmailMCPToolkit from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.linkedin_toolkit import LinkedInToolkit - # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead 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 @@ -32,6 +30,7 @@ from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory +from camel.messages import BaseMessage async def social_media_agent(options: Chat): @@ -70,6 +69,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 +98,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/toolkit/skill_toolkit.py b/backend/app/agent/toolkit/skill_toolkit.py index 4f99270aa..483c0f5d9 100644 --- a/backend/app/agent/toolkit/skill_toolkit.py +++ b/backend/app/agent/toolkit/skill_toolkit.py @@ -13,23 +13,18 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= """ -Skill Toolkit with two-tier hierarchy: -1. Project-level: /skills and /.eigent/skills -2. Global: ~/.eigent/skills/ +Skill Toolkit with multi-tier hierarchy: -Priority: Project > Global - -Agent access control is managed via skills-config.json, not by physical separation. +Agent access control is managed via skills-config.json. +User isolation is managed via ~/.eigent//skills-config.json. """ import json import logging -import re -import time -from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, Optional +from camel.toolkits.function_tool import FunctionTool from camel.toolkits.skill_toolkit import SkillToolkit as BaseSkillToolkit logger = logging.getLogger(__name__) @@ -37,217 +32,8 @@ SKILL_FILENAME = "SKILL.md" SKILL_CONFIG_FILENAME = "skills-config.json" -SkillScope = Literal["global", "project"] - - -@dataclass -class SkillMetadata: - """Metadata for a discovered skill.""" - - name: str - path: Path - scope: SkillScope - - def load_content(self) -> str: - """Lazy load skill content when needed.""" - return self.path.read_text(encoding="utf-8", errors="replace") - - -def _skill_roots_for_discovery( - working_directory: Path | None = None, - agent_name: str | None = None, -) -> list[tuple[SkillScope, Path]]: - """Return ordered list of skill root paths with scope labels. - - Args: - working_directory: Current working directory - agent_name: Unused, kept for backward compatibility - - Returns: - List of (scope, path) tuples in priority order (highest first) - """ - wd = working_directory if working_directory is not None else Path.cwd() - wd = wd if isinstance(wd, Path) else Path(wd) - roots: list[tuple[SkillScope, Path]] = [] - - # 1. Project-level skills (highest priority) - roots.append(("project", wd / "skills")) - roots.append(("project", wd / ".eigent" / "skills")) - - # 2. Global skills (lowest priority) - roots.append(("global", Path.home() / ".eigent" / "skills")) - - return roots - - -class SkillDiscovery: - """Efficient skill discovery with caching and lazy loading.""" - - _cache: dict[str, dict[str, SkillMetadata]] = {} - _cache_timestamp: dict[str, float] = {} - _cache_ttl: float = 60.0 # Cache for 60 seconds - - @classmethod - def _build_cache_key( - cls, - working_directory: Path | None, - agent_name: str | None, - ) -> str: - """Build a unique cache key.""" - wd = str(working_directory) if working_directory else "None" - agent = agent_name or "None" - return f"{wd}::{agent}" - - @classmethod - def _is_cache_valid(cls, cache_key: str) -> bool: - """Check if cache is still valid.""" - if cache_key not in cls._cache_timestamp: - return False - age = time.time() - cls._cache_timestamp[cache_key] - return age < cls._cache_ttl - - @classmethod - def _scan_root( - cls, root: Path, scope: SkillScope - ) -> dict[str, SkillMetadata]: - """Scan a single root directory for skills. - - Supports symbolic links to avoid duplication. - - Returns: - Dict mapping skill names (lowercase) to SkillMetadata - """ - skills: dict[str, SkillMetadata] = {} - - if not root.is_dir(): - return skills - - try: - for entry in root.iterdir(): - # Support symlinks - resolve to actual directory - try: - # Check if it's a directory (following symlinks) - if not entry.is_dir(): - continue - except OSError: - # Broken symlink - logger.debug(f"Skipping broken symlink: {entry}") - continue - - # Skip hidden directories - if entry.name.startswith("."): - continue - - # Follow symlink to find SKILL.md - skill_file = entry / SKILL_FILENAME - try: - # Check if file exists (following symlinks) - if not skill_file.is_file(): - continue - except OSError: - # Broken symlink - logger.debug(f"Skipping broken symlink: {skill_file}") - continue - - # Parse frontmatter to get skill name - try: - content = skill_file.read_text( - encoding="utf-8", errors="replace" - ) - parsed_name = _parse_skill_name_from_frontmatter(content) - if parsed_name: - name_lower = parsed_name.lower().strip() - # Resolve symlink to get the real path - real_path = skill_file.resolve() - skills[name_lower] = SkillMetadata( - name=parsed_name, - path=real_path, # Use resolved path - scope=scope, - ) - - # Log if it's a symlink - if skill_file != real_path: - logger.debug( - f"Found skill '{parsed_name}' via symlink: " - f"{skill_file} -> {real_path}" - ) - except (OSError, UnicodeDecodeError) as e: - logger.debug(f"Failed to read skill at {skill_file}: {e}") - continue - - except OSError as e: - logger.warning(f"Error scanning root {root}: {e}") - - return skills - - @classmethod - def discover( - cls, - working_directory: Path | None = None, - agent_name: str | None = None, - ) -> dict[str, SkillMetadata]: - """Discover all available skills with caching. - - Returns a dict mapping skill names (lowercase) to SkillMetadata. - Higher priority skills override lower priority ones. - - Args: - working_directory: Current working directory - agent_name: Name of the agent requesting skills - - Returns: - Dict of {skill_name_lower: SkillMetadata} - """ - cache_key = cls._build_cache_key(working_directory, agent_name) - - # Return cached result if valid - if cls._is_cache_valid(cache_key): - logger.debug(f"Using cached skill discovery for {cache_key}") - return cls._cache[cache_key] - - # Build skill index from all roots (reverse order for priority) - skills: dict[str, SkillMetadata] = {} - roots = _skill_roots_for_discovery(working_directory, agent_name) - - # Scan in reverse order so higher priority roots override - for scope, root in reversed(roots): - discovered = cls._scan_root(root, scope) - skills.update(discovered) - - # Cache the result - cls._cache[cache_key] = skills - cls._cache_timestamp[cache_key] = time.time() - - logger.debug( - f"Discovered {len(skills)} skills for {cache_key}: " - f"{list(skills.keys())}" - ) - - return skills - - @classmethod - def clear_cache(cls, cache_key: str | None = None) -> None: - """Clear cache for a specific key or all keys.""" - if cache_key: - cls._cache.pop(cache_key, None) - cls._cache_timestamp.pop(cache_key, None) - else: - cls._cache.clear() - cls._cache_timestamp.clear() - - -def _parse_skill_name_from_frontmatter(content: str) -> str | None: - """Extract frontmatter 'name' from SKILL.md content.""" - if not content.strip().startswith("---"): - return None - match = re.search(r"^---\s*\n(.*?)\n---", content, re.DOTALL) - if not match: - return None - fm = match.group(1) - name_m = re.search(r"^name:\s*(.+)$", fm, re.MULTILINE) - if not name_m: - return None - return name_m.group(1).strip() +# Unified scope naming +SkillScope = Literal["repo", "user", "system"] def _get_user_config_path(user_id: str | None = None) -> Path: @@ -350,13 +136,34 @@ def _is_agent_allowed( 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 - # Check if current agent is in the allowed list if not agent_name: logger.warning( f"No agent_name provided for skill '{skill_name}' " @@ -367,123 +174,18 @@ def _is_agent_allowed( return agent_name in allowed_agents -def get_skill_content_by_name( - working_directory: str | None = None, - skill_name: str = "", - agent_name: str | None = None, - user_id: str | None = None, - check_enabled: bool = True, -) -> str | None: - """Resolve a skill by name and return its SKILL.md content. - - Searches in priority order: - 1. Project-level: /skills, /.eigent/skills - 2. Global: ~/.eigent/skills/ - - Args: - working_directory: Directory to search from - skill_name: Name of the skill to load - agent_name: Name of the agent requesting the skill - user_id: User identifier for loading user-specific config - check_enabled: If True, check if skill is enabled (default: True) - - Returns: - Skill content if found and allowed, None otherwise - """ - name = (skill_name or "").strip() - if not name: - logger.warning( - "get_skill_content_by_name called with empty skill_name" - ) - return None - - wd = Path(working_directory) if working_directory else None - - # Load config and check permissions - if check_enabled: - config = _get_merged_skill_config(wd, user_id) - - # Check if skill is enabled - if not _is_skill_enabled(name, config): - logger.warning( - f"⚠️ Skill '{name}' is disabled for user '{user_id or 'legacy'}'" - ) - return None - - # Check if agent is allowed to use this skill - if not _is_agent_allowed(name, agent_name, config): - logger.warning( - f"⚠️ Agent '{agent_name}' is not allowed to use skill '{name}'" - ) - return None - - # Discover skills using efficient cached mechanism - skills = SkillDiscovery.discover(wd, agent_name) - name_lower = name.lower() - - # Look up skill in discovered index - metadata = skills.get(name_lower) - if metadata: - logger.info( - f"✅ Found skill '{name}' at {metadata.scope} level: {metadata.path} " - f"(user_id={user_id or 'legacy'})" - ) - content = metadata.load_content() - logger.debug(f"📦 Loaded skill content: {len(content)} characters") - return content - - logger.warning( - f"❌ Skill '{name}' not found (user_id={user_id or 'legacy'})" - ) - return None - - -def list_available_skills( - working_directory: str | None = None, - agent_name: str | None = None, - user_id: str | None = None, - check_enabled: bool = True, -) -> list[dict[str, str]]: - """List all available skills for an agent. - - Args: - working_directory: Directory to search from - agent_name: Name of the agent requesting skills - user_id: User identifier for loading user-specific config - check_enabled: If True, filter by enabled status and agent permissions - - Returns: - List of dicts with 'name', 'scope', and 'path' keys - """ - wd = Path(working_directory) if working_directory else None - skills = SkillDiscovery.discover(wd, agent_name) - - if check_enabled: - config = _get_merged_skill_config(wd, user_id) - # Filter by enabled status and agent permissions - skills = { - name: meta - for name, meta in skills.items() - if _is_skill_enabled(meta.name, config) - and _is_agent_allowed(meta.name, agent_name, config) - } - - return [ - { - "name": meta.name, - "scope": meta.scope, - "path": str(meta.path), - } - for meta in skills.values() - ] - - class SkillToolkit(BaseSkillToolkit): - """SkillToolkit with two-tier hierarchy. + """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. Project-level: /skills and .eigent/skills - 2. Global: ~/.eigent/skills/ + 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. @@ -496,12 +198,12 @@ def toolkit_name(cls) -> str: 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, + agent_name: Optional[str] = None, + working_directory: Optional[str] = None, + user_id: Optional[str] = None, + timeout: Optional[float] = None, ) -> None: - """Initialize SkillToolkit with agent context. + """Initialize SkillToolkit with Eigent-specific context. Args: api_task_id: Task/project identifier for logging @@ -523,25 +225,115 @@ def __init__( ) def _skill_roots(self) -> list[tuple[str, Path]]: - """Return skill roots with three-tier precedence. + """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 (label, path) tuples in priority order + List of (scope, path) tuples in priority order """ - # Reuse the centralized discovery function - roots = _skill_roots_for_discovery( - self.working_directory, self.agent_name - ) + 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")) - # Fall back to CAMEL's default roots - roots.extend(super()._skill_roots()) + # 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}: " + f"{len(roots)} paths" + ) return roots - def clear_cache(self) -> None: - """Clear the skill discovery cache for this toolkit's context.""" - cache_key = SkillDiscovery._build_cache_key( - self.working_directory, self.agent_name + 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'})" ) - SkillDiscovery.clear_cache(cache_key) - logger.debug(f"Cleared skill cache for {cache_key}") + return tools diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 36566722c..7996e9e43 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -20,24 +20,10 @@ from pathlib import Path from typing import Any -from camel.models import ModelProcessingError -from camel.tasks import Task -from camel.toolkits import ToolkitMessageIntegration -from camel.types import ModelPlatformType -from fastapi import Request -from inflection import titleize -from pydash import chain - from app.agent.agent_model import agent_model -from app.agent.factory import ( - browser_agent, - developer_agent, - document_agent, - mcp_agent, - multi_modal_agent, - question_confirm_agent, - task_summary_agent, -) +from app.agent.factory import (browser_agent, developer_agent, document_agent, + mcp_agent, multi_modal_agent, + question_confirm_agent, task_summary_agent) 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 @@ -45,23 +31,22 @@ 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 -from app.service.task import ( - Action, - ActionDecomposeProgressData, - ActionDecomposeTextData, - ActionImproveData, - ActionInstallMcpData, - ActionNewAgent, - Agents, - TaskLock, - delete_task_lock, - set_current_task_id, -) +from app.service.task import (Action, ActionDecomposeProgressData, + ActionDecomposeTextData, ActionImproveData, + ActionInstallMcpData, ActionNewAgent, Agents, + TaskLock, delete_task_lock, set_current_task_id) from app.utils.event_loop_utils import set_main_event_loop from app.utils.file_utils import get_working_directory from app.utils.server.sync_step import sync_step from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback from app.utils.workforce import Workforce +from camel.models import ModelProcessingError +from camel.tasks import Task +from camel.toolkits import ToolkitMessageIntegration +from camel.types import ModelPlatformType +from fastapi import Request +from inflection import titleize +from pydash import chain logger = logging.getLogger("chat_service") @@ -308,51 +293,6 @@ def build_conversation_context( return context - -# When the user mentions a skill in double curly braces (e.g. {{Data Analyzer}}), -# the coordinator must actively load that skill using tools. -_COORDINATOR_SKILL_INSTRUCTION = """ -🎯 CRITICAL: SKILL-BASED TASK DECOMPOSITION PROTOCOL - -When the user mentions a skill with double curly braces (e.g., {{pdf}}, {{excalidraw}}, {{data-analyzer}}), -you are REQUIRED to use the SkillToolkit to load that skill. DO NOT access skill files directly. - -**MANDATORY WORKFLOW (NO EXCEPTIONS):** - -1. **ALWAYS call `list_skills()` first** - - This shows all available skills - - Verify the mentioned skill exists - - If the skill doesn't exist, inform the user immediately - -2. **ALWAYS call `load_skill("")` second** - - Pass the exact skill name (lowercase, hyphenated) - - The returned content is the AUTHORITATIVE reference - - Contains examples, patterns, API documentation, and best practices - -3. **Design subtasks based ONLY on the loaded skill content** - - Follow code examples and patterns from the skill - - Reference specific sections, functions, and parameters mentioned - - DO NOT use general knowledge or assumptions - - DO NOT access files like /Users/.../.eigent/skills/.../ directly - -4. **Important**: - - You MUST load the skill before designing subtasks (no shortcuts!) - - The skill name in double braces is just a reference - always load it explicitly - - If `list_skills` shows no matching skill, inform the user - -**Example (CORRECT):** -User: "I just added the {{excalidraw}} skill for Eigent, can you make something amazing with this skill?" -You: -1. Call `list_skills()` → Verify "excalidraw" exists -2. Call `load_skill("excalidraw")` → Get full Excalidraw documentation -3. Design subtasks based on examples in the loaded content - -""" - -# Skills are now loaded explicitly by agents using list_skills/load_skill tools -# rather than being auto-injected based on {{skill}} syntax. - - def build_context_for_workforce( task_lock: TaskLock, options: Chat, @@ -361,10 +301,9 @@ def build_context_for_workforce( """Build context information for workforce. Instructs coordinator to actively load skills using list_skills/load_skill tools. """ - base = build_conversation_context( + return build_conversation_context( task_lock, header="=== CONVERSATION HISTORY ===" ) - return _COORDINATOR_SKILL_INSTRUCTION.strip() + "\n\n" + base @sync_step @@ -1036,9 +975,8 @@ async def run_decomposition(): logger.info("[LIFECYCLE] 🛑 Stopping workforce") if workforce._running: # Import correct BaseWorkforce from camel - from camel.societies.workforce.workforce import ( - Workforce as BaseWorkforce, - ) + from camel.societies.workforce.workforce import \ + Workforce as BaseWorkforce BaseWorkforce.stop(workforce) logger.info( diff --git a/electron/main/index.ts b/electron/main/index.ts index 54e03907a..3e821e39a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -990,9 +990,25 @@ function registerIpcHandlers() { async function loadSkillConfig(userId: string): Promise { const configPath = getSkillConfigPath(userId); + + // Auto-create config file if it doesn't exist if (!existsSync(configPath)) { - return { version: 1, skills: {} }; + 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); @@ -1024,9 +1040,13 @@ function registerIpcHandlers() { try { const config = await loadSkillConfig(userId); if (!config.skills[skillName]) { + // Use SkillScope object format config.skills[skillName] = { enabled, - scope: 'global', + scope: { + isGlobal: true, + selectedAgents: [], + }, addedAt: Date.now(), isExample: false, }; @@ -1072,6 +1092,75 @@ function registerIpcHandlers() { } ); + // 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 ( diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8e4cc893b..edd0c74a4 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -169,7 +169,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('skill-import-zip', zipPathOrBuffer), openSkillFolder: (skillName: string) => ipcRenderer.invoke('open-skill-folder', skillName), - // Skills config + 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) => 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/src/pages/Agents/components/SkillListItem.tsx b/src/pages/Agents/components/SkillListItem.tsx index d44467419..174fe48d5 100644 --- a/src/pages/Agents/components/SkillListItem.tsx +++ b/src/pages/Agents/components/SkillListItem.tsx @@ -88,7 +88,7 @@ export default function SkillListItem(props: SkillListItemProps) {
{ + 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) @@ -171,11 +180,11 @@ export default function SkillListItem(props: SkillListItemProps) { }; return ( -
+
{/* Row 1: Name / Actions */}
- + {skill.name}
@@ -208,8 +217,8 @@ export default function SkillListItem(props: SkillListItemProps) {
{/* Row 2: Description full width / wrapped */} -
-

+

+

{skill.description}

@@ -229,12 +238,12 @@ export default function SkillListItem(props: SkillListItemProps) { {scopeOpen && ( -
+
{/* All agents as first tab; then each agent toggle */} {allAgents.map((agentName) => { - const isSelected = skill.scope.selectedAgents.includes(agentName); + const isSelected = + isAllAgentsSelected || + skill.scope.selectedAgents.includes(agentName); const display = getWorkflowAgentDisplay(agentName); const icon = display?.icon ?? ( @@ -259,7 +270,7 @@ export default function SkillListItem(props: SkillListItemProps) { key={agentName} type="button" onClick={() => handleToggleAgent(agentName)} - className={`inline-flex items-center gap-2 rounded-full bg-surface-primary px-2 py-1 text-label-xs font-medium text-text-primary transition-opacity [&>svg]:shrink-0 hover:opacity-100 ${ + className={`inline-flex items-center gap-2 rounded-full bg-surface-primary px-2 py-1 text-label-xs font-medium text-text-primary transition-opacity hover:opacity-100 [&>svg]:shrink-0 ${ isSelected ? 'opacity-100 [&>svg]:text-icon-success' : 'opacity-50 [&>svg]:text-inherit' diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts index 3ffc02f68..74c192d8e 100644 --- a/src/store/skillsStore.ts +++ b/src/store/skillsStore.ts @@ -53,8 +53,6 @@ export interface Skill { isExample: boolean; } -// Dir names of default skills seeded by main process under ~/.eigent/skills. -// These are shown in the "Example skills" section; all other disk skills are "Your skills". export const EXAMPLE_SKILL_DIR_NAMES = [ 'code-reviewer', 'report-writer', @@ -67,7 +65,7 @@ interface SkillsState { addSkill: ( skill: Omit ) => Promise; - updateSkill: (id: string, updates: Partial) => void; + updateSkill: (id: string, updates: Partial) => Promise; deleteSkill: (id: string) => Promise; toggleSkill: (id: string) => Promise; getSkillsByType: (isExample: boolean) => Skill[]; @@ -118,13 +116,12 @@ export const useSkillsStore = create()( try { const userId = emailToUserId(useAuthStore.getState().email); if (userId) { - const scope = newSkill.scope.isGlobal ? 'global' : 'project'; await window.electronAPI.skillConfigUpdate( userId, newSkill.name, { enabled: newSkill.enabled, - scope, + scope: newSkill.scope, addedAt: newSkill.addedAt, isExample: false, } @@ -141,12 +138,44 @@ export const useSkillsStore = create()( })); }, - updateSkill: (id, updates) => { + updateSkill: async (id, updates) => { + const skill = get().skills.find((s) => s.id === id); + if (!skill) return; + set((state) => ({ - skills: state.skills.map((skill) => - skill.id === id ? { ...skill, ...updates } : skill + 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) => { @@ -224,26 +253,40 @@ export const useSkillsStore = create()( return get().skills.filter((skill) => skill.isExample === isExample); }, - // Load skills from ~/.eigent/skills (main process seeds example skills when empty) + // Load skills from ~/.eigent/skills syncFromDisk: async () => { if (!hasSkillsFsApi()) return; try { - // 1. Scan skills from filesystem + const userId = emailToUserId(useAuthStore.getState().email); + const result = await window.electronAPI.skillsScan(); if (!result.success || !result.skills) return; - // 2. Load configuration from local file via Electron IPC + if (userId) { + console.log(`[Skills] Initializing config for user: ${userId}`); + await window.electronAPI.skillConfigInit(userId); + } + let config: any = { global: null, project: null }; try { - const userId = emailToUserId(useAuthStore.getState().email); 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.warn('Failed to load skill config, using defaults:', error); + console.error('[Skills] Error loading skill config:', error); } const prevByKey = new Map( @@ -264,11 +307,28 @@ export const useSkillsStore = create()( EXAMPLE_SKILL_DIR_NAMES as readonly string[] ).includes(s.skillDirName); - // Get enabled status from config (project overrides global) + // Get config from global/project const globalConfig = config.global?.skills?.[s.name]; const projectConfig = config.project?.skills?.[s.name]; - const enabledFromConfig = - projectConfig?.enabled ?? globalConfig?.enabled ?? true; + 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}`, @@ -277,13 +337,11 @@ export const useSkillsStore = create()( filePath: s.path, fileContent: existing?.fileContent ?? '', skillDirName: s.skillDirName, - addedAt: existing?.addedAt ?? Date.now(), - scope: existing?.scope ?? { - isGlobal: true, - selectedAgents: [], - }, + addedAt: + skillConfig?.addedAt ?? existing?.addedAt ?? Date.now(), + scope: scopeFromConfig, enabled: enabledFromConfig, - isExample, + isExample: skillConfig?.isExample ?? isExample, }; } ) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f4fe0e3e0..04648c3cb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -170,7 +170,9 @@ interface ElectronAPI { openSkillFolder: ( skillName: string ) => Promise<{ success: boolean; error?: string }>; - // Skills config (~/.eigent//skills-config.json) + skillConfigInit: ( + userId: string + ) => Promise<{ success: boolean; config?: any; error?: string }>; skillConfigLoad: ( userId: string ) => Promise<{ success: boolean; config?: any; error?: string }>; From 7206787e9e904869d6d08f555f12539156e14257 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 14 Feb 2026 01:09:29 +0800 Subject: [PATCH 06/12] prompt for SOCIAL_MEDIA_SYS_PROMPT --- backend/app/agent/prompt.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 392b3a0e5..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()`. From a2431ec1eabe9e3a166ef8b4bd9e862f91c995d1 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 14 Feb 2026 01:16:51 +0800 Subject: [PATCH 07/12] :fire: remove skill_config_api.router --- backend/app/api/skill_config_api.py | 278 ---------------------------- backend/app/router.py | 6 - 2 files changed, 284 deletions(-) delete mode 100644 backend/app/api/skill_config_api.py diff --git a/backend/app/api/skill_config_api.py b/backend/app/api/skill_config_api.py deleted file mode 100644 index e21310170..000000000 --- a/backend/app/api/skill_config_api.py +++ /dev/null @@ -1,278 +0,0 @@ -# ========= 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. ========= - -"""API endpoints for managing skill configurations (enable/disable).""" - -import json -import logging -from pathlib import Path -from typing import Literal - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/skills/config", tags=["skills"]) - -SKILL_CONFIG_FILENAME = "skills-config.json" - - -class SkillConfigItem(BaseModel): - """Configuration for a single skill.""" - - enabled: bool - scope: Literal["global", "project"] | None = None - addedAt: int | None = None - isExample: bool | None = None - - -class SkillConfigUpdate(BaseModel): - """Request body for updating skill configuration.""" - - scope: Literal["global", "project"] = "global" - skillName: str - config: SkillConfigItem - - -class SkillToggleRequest(BaseModel): - """Request body for toggling a skill.""" - - enabled: bool - scope: Literal["global", "project"] = "global" - - -def get_config_path( - scope: str, - user_id: str | None = None, - project_path: str | None = None, -) -> Path: - """Get the path to the skill config file based on scope. - - Args: - scope: Configuration scope ('global' or 'project') - user_id: User identifier for user-specific config - project_path: Project path (required for 'project' scope) - - Returns: - Path to config file - """ - if scope == "global": - 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 - elif scope == "project" and project_path: - return Path(project_path) / ".eigent" / SKILL_CONFIG_FILENAME - else: - raise ValueError(f"Invalid scope '{scope}' or missing project_path") - - -def load_config(config_path: Path) -> dict: - """Load skill configuration from JSON file.""" - if not config_path.exists(): - return {"version": 1, "skills": {}} - - try: - with open(config_path, encoding="utf-8") as f: - data = json.load(f) - # Ensure structure - if "version" not in data: - data["version"] = 1 - if "skills" not in data: - data["skills"] = {} - return data - except (json.JSONDecodeError, OSError) as e: - logger.error(f"Failed to load config from {config_path}: {e}") - return {"version": 1, "skills": {}} - - -def save_config(config_path: Path, config: dict) -> None: - """Save skill configuration to JSON file.""" - # Ensure directory exists - config_path.parent.mkdir(parents=True, exist_ok=True) - - try: - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - logger.info(f"Saved skill config to {config_path}") - except OSError as e: - logger.error(f"Failed to save config to {config_path}: {e}") - raise HTTPException( - status_code=500, detail=f"Failed to save configuration: {e}" - ) - - -@router.get("/") -async def get_skill_configs( - user_id: str | None = None, project_path: str | None = None -): - """Get both user-global and project skill configurations. - - Args: - user_id: User identifier for loading user-specific config - project_path: Optional path to project for project-level config - """ - user_global_path = get_config_path("global", user_id=user_id) - user_global_config = load_config(user_global_path) - - result = { - "global": user_global_config, - "project": None, - "user_id": user_id, - } - - if project_path: - try: - project_path_obj = ( - Path(project_path) / ".eigent" / SKILL_CONFIG_FILENAME - ) - if project_path_obj.exists(): - result["project"] = load_config(project_path_obj) - except Exception as e: - logger.warning(f"Failed to load project config: {e}") - - return result - - -@router.post("/update") -async def update_skill_config( - update: SkillConfigUpdate, - user_id: str | None = None, - project_path: str | None = None, -): - """Update configuration for a specific skill. - - Args: - update: Configuration update containing scope, skillName, and config - user_id: User identifier for user-specific config - project_path: Required if scope is 'project' - """ - try: - config_path = get_config_path( - update.scope, user_id=user_id, project_path=project_path - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Load existing config - config = load_config(config_path) - - # Update the specific skill - config["skills"][update.skillName] = update.config.model_dump( - exclude_none=True - ) - - # Save back - save_config(config_path, config) - - return { - "success": True, - "message": f"Updated skill '{update.skillName}' in {update.scope} config", - "user_id": user_id, - } - - -@router.post("/{skill_name}/toggle") -async def toggle_skill( - skill_name: str, - toggle: SkillToggleRequest, - user_id: str | None = None, - project_path: str | None = None, -): - """Toggle a skill on or off. - - Args: - skill_name: Name of the skill to toggle - toggle: Toggle request with enabled status and scope - user_id: User identifier for user-specific config - project_path: Required if scope is 'project' - """ - try: - config_path = get_config_path( - toggle.scope, user_id=user_id, project_path=project_path - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Load existing config - config = load_config(config_path) - - # Get existing skill config or create new one - if skill_name in config["skills"]: - skill_config = config["skills"][skill_name] - skill_config["enabled"] = toggle.enabled - else: - # Create new entry - skill_config = { - "enabled": toggle.enabled, - "scope": toggle.scope, - "addedAt": int(__import__("time").time() * 1000), - } - config["skills"][skill_name] = skill_config - - # Save back - save_config(config_path, config) - - return { - "success": True, - "message": f"Skill '{skill_name}' {'enabled' if toggle.enabled else 'disabled'} in {toggle.scope} config", - "skill": skill_config, - "user_id": user_id, - } - - -@router.delete("/{skill_name}") -async def delete_skill_config( - skill_name: str, - scope: Literal["global", "project"] = "global", - user_id: str | None = None, - project_path: str | None = None, -): - """Remove a skill from configuration (revert to default). - - Args: - skill_name: Name of the skill to remove from config - scope: Configuration scope - user_id: User identifier for user-specific config - project_path: Required if scope is 'project' - """ - try: - config_path = get_config_path( - scope, user_id=user_id, project_path=project_path - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Load existing config - config = load_config(config_path) - - # Remove the skill if it exists - if skill_name in config["skills"]: - del config["skills"][skill_name] - save_config(config_path, config) - return { - "success": True, - "message": f"Removed '{skill_name}' from {scope} config", - "user_id": user_id, - } - else: - raise HTTPException( - status_code=404, - detail=f"Skill '{skill_name}' not found in {scope} config", - ) diff --git a/backend/app/router.py b/backend/app/router.py index db11e44c6..f257dde08 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -21,7 +21,6 @@ from fastapi import FastAPI -from app.api import skill_config_api from app.controller import ( chat_controller, health_controller, @@ -72,11 +71,6 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management", }, - { - "router": skill_config_api.router, - "tags": ["skills"], - "description": "Skill configuration management (enable/disable skills)", - }, ] for config in routers_config: From 41a522e616bb0c910b321ed6f0f2047b7bda586d Mon Sep 17 00:00:00 2001 From: Weijie Bai Date: Mon, 16 Feb 2026 03:01:02 +0000 Subject: [PATCH 08/12] Fix: skill load (#1277) --- src/lib/skillToolkit.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/skillToolkit.ts b/src/lib/skillToolkit.ts index 3f4ef7896..bed9c828f 100644 --- a/src/lib/skillToolkit.ts +++ b/src/lib/skillToolkit.ts @@ -71,7 +71,10 @@ function parseSimpleYaml(text: string): Record { if (match) { const value = match[2].trim(); // Lowercase key so `Name:` / `name:` / `NAME:` all work - out[match[1].toLowerCase()] = value.replace(/^['"]|['"]$/g, '').trim(); + out[match[1].toLowerCase()] = value + .replace(/^['"]|['"]$/g, '') + .replace(/\\"/g, '"') + .trim(); } } return out; @@ -106,7 +109,7 @@ export function buildSkillMd( const front = [ FRONTMATTER_DELIM, `name: ${name}`, - `description: ${description}`, + `description: "${description.replace(/"/g, '\\"')}"`, FRONTMATTER_DELIM, '', body, From 1869f73bd84bd29034267a728127aeb66f71586d Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Mon, 16 Feb 2026 15:22:11 +0800 Subject: [PATCH 09/12] feat(skills): example skills no delete, agents tab reorder, SkillToolkit for new worker, description overflow fix --- backend/app/service/chat_service.py | 55 +++++++++++++------ src/pages/Agents/Skills.tsx | 9 +-- src/pages/Agents/components/SkillListItem.tsx | 36 +++++++----- src/pages/History.tsx | 16 +++--- src/store/skillsStore.ts | 3 + 5 files changed, 77 insertions(+), 42 deletions(-) diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 7996e9e43..aa10de74d 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -20,10 +20,24 @@ from pathlib import Path from typing import Any +from camel.models import ModelProcessingError +from camel.tasks import Task +from camel.toolkits import ToolkitMessageIntegration +from camel.types import ModelPlatformType +from fastapi import Request +from inflection import titleize +from pydash import chain + from app.agent.agent_model import agent_model -from app.agent.factory import (browser_agent, developer_agent, document_agent, - mcp_agent, multi_modal_agent, - question_confirm_agent, task_summary_agent) +from app.agent.factory import ( + browser_agent, + developer_agent, + document_agent, + mcp_agent, + multi_modal_agent, + question_confirm_agent, + task_summary_agent, +) 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 @@ -31,22 +45,23 @@ 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 -from app.service.task import (Action, ActionDecomposeProgressData, - ActionDecomposeTextData, ActionImproveData, - ActionInstallMcpData, ActionNewAgent, Agents, - TaskLock, delete_task_lock, set_current_task_id) +from app.service.task import ( + Action, + ActionDecomposeProgressData, + ActionDecomposeTextData, + ActionImproveData, + ActionInstallMcpData, + ActionNewAgent, + Agents, + TaskLock, + delete_task_lock, + set_current_task_id, +) from app.utils.event_loop_utils import set_main_event_loop from app.utils.file_utils import get_working_directory from app.utils.server.sync_step import sync_step from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback from app.utils.workforce import Workforce -from camel.models import ModelProcessingError -from camel.tasks import Task -from camel.toolkits import ToolkitMessageIntegration -from camel.types import ModelPlatformType -from fastapi import Request -from inflection import titleize -from pydash import chain logger = logging.getLogger("chat_service") @@ -293,6 +308,7 @@ def build_conversation_context( return context + def build_context_for_workforce( task_lock: TaskLock, options: Chat, @@ -975,8 +991,9 @@ async def run_decomposition(): logger.info("[LIFECYCLE] 🛑 Stopping workforce") if workforce._running: # Import correct BaseWorkforce from camel - from camel.societies.workforce.workforce import \ - Workforce as BaseWorkforce + from camel.societies.workforce.workforce import ( + Workforce as BaseWorkforce, + ) BaseWorkforce.stop(workforce) logger.info( @@ -2246,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/src/pages/Agents/Skills.tsx b/src/pages/Agents/Skills.tsx index 1f982a2a2..1dd8b659d 100644 --- a/src/pages/Agents/Skills.tsx +++ b/src/pages/Agents/Skills.tsx @@ -88,7 +88,7 @@ export default function Skills() {
{t('agents.your-skills')} - {t('agents.example-skills')} - + */}
handleDeleteClick(skill)} + onDelete={undefined} /> ))}
diff --git a/src/pages/Agents/components/SkillListItem.tsx b/src/pages/Agents/components/SkillListItem.tsx index 174fe48d5..1b275e550 100644 --- a/src/pages/Agents/components/SkillListItem.tsx +++ b/src/pages/Agents/components/SkillListItem.tsx @@ -19,6 +19,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { TooltipSimple } from '@/components/ui/tooltip'; import { getWorkflowAgentDisplay, WORKFLOW_AGENT_LIST, @@ -43,7 +44,7 @@ import { useNavigate } from 'react-router-dom'; interface SkillListItemDefaultProps { variant?: 'default'; skill: Skill; - onDelete: () => void; + onDelete?: () => void; message?: never; addButtonText?: never; onAddClick?: never; @@ -204,24 +205,31 @@ export default function SkillListItem(props: SkillListItemProps) { {t('agents.try-in-chat')} - - - {t('layout.delete')} - + {!skill.isExample && onDelete && ( + + + {t('layout.delete')} + + )}
- {/* Row 2: Description full width / wrapped */} -
-

- {skill.description} -

-
+ {/* Row 2: Description - 5 lines max, hover shows full */} + +
+

+ {skill.description} +

+
+
{/* Row 3: Added time / Skill scope */}
diff --git a/src/pages/History.tsx b/src/pages/History.tsx index c2786b0cf..45b8b73a4 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -155,6 +155,14 @@ export default function Home() { > {t('layout.projects')} + } + > + {t('setting.agents')} + {t('layout.browser')} - } - > - {t('setting.agents')} - ()( 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(() => { From 93ee3da832fae73e6172ce0baf19df0b07507216 Mon Sep 17 00:00:00 2001 From: Tong Chen Date: Mon, 16 Feb 2026 15:30:52 +0800 Subject: [PATCH 10/12] Potential fix for code scanning alert no. 103: Incomplete string escaping or encoding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/lib/skillToolkit.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/skillToolkit.ts b/src/lib/skillToolkit.ts index bed9c828f..3a84bd945 100644 --- a/src/lib/skillToolkit.ts +++ b/src/lib/skillToolkit.ts @@ -106,10 +106,13 @@ export function buildSkillMd( description: string, body: string ): string { + const escapedDescription = description + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); const front = [ FRONTMATTER_DELIM, `name: ${name}`, - `description: "${description.replace(/"/g, '\\"')}"`, + `description: "${escapedDescription}"`, FRONTMATTER_DELIM, '', body, From 55ed0f92d99517a5862aeb01da7d9d9c9cb042e5 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Mon, 16 Feb 2026 15:37:04 +0800 Subject: [PATCH 11/12] fix: apply ruff lint and format fixes --- .github/PULL_REQUEST_TEMPLATE.md | 1 + CONTRIBUTING.md | 14 +++++++------- backend/app/agent/factory/social_media.py | 4 +++- backend/app/agent/toolkit/skill_toolkit.py | 17 +++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) 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/backend/app/agent/factory/social_media.py b/backend/app/agent/factory/social_media.py index 19ab7ed08..77b8b86c4 100644 --- a/backend/app/agent/factory/social_media.py +++ b/backend/app/agent/factory/social_media.py @@ -11,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +from camel.messages import BaseMessage + from app.agent.agent_model import agent_model from app.agent.listen_chat_agent import logger from app.agent.prompt import SOCIAL_MEDIA_SYS_PROMPT @@ -18,6 +20,7 @@ from app.agent.toolkit.google_gmail_mcp_toolkit import GoogleGmailMCPToolkit from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.linkedin_toolkit import LinkedInToolkit + # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.notion_mcp_toolkit import NotionMCPToolkit @@ -30,7 +33,6 @@ from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -from camel.messages import BaseMessage async def social_media_agent(options: Chat): diff --git a/backend/app/agent/toolkit/skill_toolkit.py b/backend/app/agent/toolkit/skill_toolkit.py index 483c0f5d9..62ad78e1c 100644 --- a/backend/app/agent/toolkit/skill_toolkit.py +++ b/backend/app/agent/toolkit/skill_toolkit.py @@ -22,7 +22,7 @@ import json import logging from pathlib import Path -from typing import Literal, Optional +from typing import Literal from camel.toolkits.function_tool import FunctionTool from camel.toolkits.skill_toolkit import SkillToolkit as BaseSkillToolkit @@ -198,10 +198,10 @@ def toolkit_name(cls) -> str: def __init__( self, api_task_id: str, - agent_name: Optional[str] = None, - working_directory: Optional[str] = None, - user_id: Optional[str] = None, - timeout: Optional[float] = None, + 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. @@ -253,8 +253,7 @@ def _skill_roots(self) -> list[tuple[str, Path]]: roots.append(("system", Path("/etc/camel/skills"))) logger.debug( - f"Skill roots configured for {self.agent_name}: " - f"{len(roots)} paths" + f"Skill roots configured for {self.agent_name}: {len(roots)} paths" ) return roots @@ -271,9 +270,7 @@ def _apply_access_control( Filtered dict of skills based on configuration """ # Load merged config (user + project) - config = _get_merged_skill_config( - self.working_directory, self.user_id - ) + config = _get_merged_skill_config(self.working_directory, self.user_id) if not config: # No config = all skills available From 40a18050fd9de56faf9308f41a207d6727967fd9 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Mon, 16 Feb 2026 15:46:15 +0800 Subject: [PATCH 12/12] fix: apply ruff lint and format fixes --- resources/example-skills/code-reviewer/SKILL.md | 10 +++++----- resources/example-skills/data-analyzer/SKILL.md | 8 ++++---- resources/example-skills/report-writer/SKILL.md | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/resources/example-skills/code-reviewer/SKILL.md b/resources/example-skills/code-reviewer/SKILL.md index 8bf084546..15681191f 100644 --- a/resources/example-skills/code-reviewer/SKILL.md +++ b/resources/example-skills/code-reviewer/SKILL.md @@ -9,11 +9,11 @@ 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? +**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 diff --git a/resources/example-skills/data-analyzer/SKILL.md b/resources/example-skills/data-analyzer/SKILL.md index 9e0eb6dd2..82f3b7423 100644 --- a/resources/example-skills/data-analyzer/SKILL.md +++ b/resources/example-skills/data-analyzer/SKILL.md @@ -9,10 +9,10 @@ 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 +**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 diff --git a/resources/example-skills/report-writer/SKILL.md b/resources/example-skills/report-writer/SKILL.md index 81c7fb8c9..2443fd9cd 100644 --- a/resources/example-skills/report-writer/SKILL.md +++ b/resources/example-skills/report-writer/SKILL.md @@ -9,11 +9,11 @@ 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 +**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