update skill ui

This commit is contained in:
Douglas 2026-02-12 21:54:08 +01:00
parent b2515ed9cd
commit 9318dd4e43
46 changed files with 1407 additions and 1026 deletions

View file

@ -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)}
>
<TabsList
className={cn(
'flex flex-col gap-1.5 rounded-none border-none bg-transparent p-0',
'flex flex-col w-full gap-1.5 rounded-none border-none bg-transparent p-0',
listClassName
)}
>

View file

@ -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<HTMLInputElement>) => 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<HTMLInputElement>(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<HTMLInputElement>);
}, [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 (
<motion.div
className={cn(
'flex items-center justify-center py-0.5 overflow-hidden rounded-lg border border-solid border-transparent bg-transparent',
'focus-within:border-input-border-focus focus-within:bg-input-bg-input',
'hover:border-transparent hover:bg-surface-tertiary'
)}
initial={false}
animate={{ width: isExpanded ? EXPANDED_WIDTH : COLLAPSED_WIDTH }}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
>
<AnimatePresence mode="wait">
{!isExpanded ? (
<motion.div
key="icon"
className="flex shrink-0 items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<TooltipSimple content={searchLabel}>
<Button
type="button"
variant="ghost"
size="icon"
onClick={expand}
aria-label={searchLabel}
>
<Search />
</Button>
</TooltipSimple>
</motion.div>
) : (
<motion.div
key="input"
className="flex min-w-0 flex-1 items-center gap-0 pr-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="pointer-events-none ml-2 inline-flex h-4 w-4 shrink-0 items-center justify-center text-icon-secondary">
<Search className="h-4 w-4" />
</span>
<input
ref={inputRef}
type="text"
value={value}
onChange={onChange}
placeholder={place}
onBlur={() => {
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"
/>
<TooltipSimple content={clearLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 rounded-full text-icon-secondary"
onClick={collapse}
aria-label={clearLabel}
>
<X />
</Button>
</TooltipSimple>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
return (
<div className="relative w-full">
<Input
size="sm"
value={value}
onChange={onChange}
placeholder={placeholder || t('setting.search-mcp')}
placeholder={place}
leadingIcon={<Search className="h-5 w-5 text-icon-secondary" />}
/>
</div>

View file

@ -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<WorkflowAgentType, AgentDisplayInfo> = {
developer_agent: {
name: 'Developer Agent',
icon: <CodeXml size={16} className="text-text-primary" />,
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: <Globe size={16} className="text-text-primary" />,
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: <FileText size={16} className="text-text-primary" />,
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: <Image size={16} className="text-text-primary" />,
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: <Bird size={16} className="text-text-primary" />,
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;
}

View file

@ -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: <CodeXml size={16} className="text-text-primary" />,
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: <Globe size={16} className="text-text-primary" />,
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: <FileText size={16} className="text-text-primary" />,
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: <Image size={16} className="text-text-primary" />,
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: <Bird size={16} className="text-text-primary" />,
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 ',

View file

@ -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"
>
<div className="p-6">
<div className="p-6 rounded-xl border border-popup-border bg-surface-tertiary shadow-perfect">
<span className="mb-2 text-body-lg font-bold text-text-primary">
{title}
</span>

View file

@ -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
) => (
<DialogPortal>
<DialogOverlay />
<DialogOverlay
className={overlayVariant === 'dark' ? 'bg-black/40' : undefined}
style={
overlayVariant === 'dark'
? { backgroundColor: 'rgba(0, 0, 0, 0.4)' }
: undefined
}
/>
<DialogPrimitive.Content
ref={ref}
className={cn(dialogContentVariants({ size }), className)}
className={cn(
dialogContentVariants({ size }),
overlayVariant === 'dark' && 'z-[51]',
className
)}
{...props}
>
{children}

View file

@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-muted text-muted-foreground inline-flex items-center justify-center rounded-xl border border-solid border-menutabs-border-default bg-menutabs-bg-default p-1',
'data-[orientation=vertical]:flex data-[orientation=vertical]:h-full data-[orientation=vertical]:w-full data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[orientation=vertical]:justify-start',
className
)}
{...props}
/>
));
TabsListProps
>(({ className, variant = 'default', ...props }, ref) => {
const tabsListRef = React.useRef<React.ElementRef<
typeof TabsPrimitive.List
> | null>(null) as React.MutableRefObject<React.ElementRef<
typeof TabsPrimitive.List
> | 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<typeof TabsPrimitive.List> | null) => {
if (typeof ref === 'function') {
ref(node);
} else if (ref && 'current' in ref) {
(
ref as React.MutableRefObject<React.ElementRef<
typeof TabsPrimitive.List
> | null>
).current = node;
}
tabsListRef.current = node;
},
[ref]
);
return (
<TabsContext.Provider value={{ variant }}>
<div className="relative">
<TabsPrimitive.List
ref={combinedRef}
className={cn(
variant === 'outline'
? 'relative inline-flex items-center justify-center gap-0 bg-surface-disabled p-0'
: 'inline-flex items-center justify-center rounded-xl border border-solid border-menutabs-border-default bg-menutabs-bg-default p-0.5',
'data-[orientation=vertical]:flex data-[orientation=vertical]:h-full data-[orientation=vertical]:w-full data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[orientation=vertical]:justify-start',
className
)}
data-variant={variant}
{...props}
/>
{variant === 'outline' && sliderStyle.width > 0 && (
<motion.div
className="absolute bottom-0 z-10 h-[1.5px] bg-text-heading"
initial={false}
animate={{
left: sliderStyle.left,
width: sliderStyle.width,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</TabsContext.Provider>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
type TabsTriggerProps = React.ComponentPropsWithoutRef<
typeof TabsPrimitive.Trigger
> & {
variant?: 'default' | 'outline';
};
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-xl bg-menutabs-fill-default px-2 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-menutabs-fill-active data-[state=active]:text-menutabs-text-active data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTriggerProps
>(({ className, variant: propVariant, ...props }, ref) => {
const { variant: contextVariant } = React.useContext(TabsContext);
const variant = propVariant || contextVariant || 'default';
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
variant === 'outline'
? 'relative flex cursor-pointer flex-row items-center justify-center gap-2 bg-transparent px-4 py-3 !text-body-sm !font-semibold text-text-label transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface-disabled data-[state=active]:!font-bold data-[state=active]:text-text-heading'
: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-xl bg-menutabs-fill-default px-2 py-1 text-body-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-menutabs-fill-active data-[state=active]:text-menutabs-text-active data-[state=active]:shadow-sm',
className
)}
data-variant={variant}
data-value={props.value}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
>(({ className, children, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className
)}
{...props}
>
<AnimatePresence mode="wait">
<motion.div
key={props.value}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-4"
>
{children}
</motion.div>
</AnimatePresence>
</TabsPrimitive.Content>
);
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View file

@ -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}

View file

@ -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": "مهارة مخصصة",

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -416,5 +416,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"
}

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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": "カスタムスキル",

View file

@ -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,

View file

@ -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": "사용자 정의 스킬",

View file

@ -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,

View file

@ -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": "Пользовательский навык",

View file

@ -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,

View file

@ -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": "自定义技能",

View file

@ -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,

View file

@ -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": "自訂技能",

View file

@ -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,

View file

@ -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<string, string> {
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;

View file

@ -19,25 +19,27 @@ export default function Memory() {
const { t } = useTranslation();
return (
<div className="space-y-8">
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="text-heading-sm font-bold text-text-heading">
{t('capabilities.memory')}
{t('agents.memory')}
</div>
</div>
{/* Coming Soon Card */}
<div className="flex flex-col items-center justify-center rounded-2xl bg-surface-secondary py-16">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-tertiary">
<Brain className="h-8 w-8 text-icon-secondary" />
{/* Content Section */}
<div className="mb-12 flex flex-col gap-6">
<div className="flex w-full flex-col items-center justify-between rounded-2xl bg-surface-secondary px-6 py-4">
<div className="flex h-16 w-16 items-center justify-center">
<Brain className="h-8 w-8 text-icon-secondary" />
</div>
<h2 className="mb-2 text-body-md font-bold text-text-heading">
{t('layout.coming-soon')}
</h2>
<p className="max-w-md text-center text-body-sm text-text-label">
{t('agents.memory-coming-soon-description')}
</p>
</div>
<h2 className="mb-2 text-body-md font-bold text-text-heading">
{t('layout.coming-soon')}
</h2>
<p className="max-w-md px-4 text-center text-body-sm text-text-label">
{t('capabilities.memory-coming-soon-description')}
</p>
</div>
</div>
);

192
src/pages/Agents/Skills.tsx Normal file
View file

@ -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<Skill | null>(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 (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="text-heading-sm font-bold text-text-heading">
{t('agents.skills')}
</div>
</div>
{/* Content Section */}
<div className="mb-12 flex flex-col gap-6">
<div className="flex w-full flex-col items-center justify-between gap-4 rounded-2xl bg-surface-secondary px-6 py-4">
<Tabs defaultValue="your-skills" className="w-full">
<div className="sticky top-[84px] z-10 flex w-full items-center justify-between gap-4 border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary bg-surface-secondary">
<TabsList
variant="outline"
className="h-auto flex-1 justify-start bg-transparent border-0"
>
<TabsTrigger
value="your-skills"
className="data-[state=active]:bg-transparent"
>
{t('agents.your-skills')}
</TabsTrigger>
<TabsTrigger
value="example-skills"
className="data-[state=active]:bg-transparent"
>
{t('agents.example-skills')}
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<SearchInput
variant="icon"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('agents.search-skills')}
/>
<Button
variant="primary"
size="sm"
onClick={() => setUploadDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t('agents.add-skill')}
</Button>
</div>
</div>
<TabsContent value="your-skills" className="mt-4">
{yourSkills.length === 0 ? (
<SkillListItem
variant="placeholder"
message={
searchQuery
? t('agents.no-skills-found')
: t('agents.no-your-skills')
}
addButtonText={
!searchQuery ? t('agents.add-your-first-skill') : undefined
}
onAddClick={
!searchQuery ? () => setUploadDialogOpen(true) : undefined
}
/>
) : (
<div className="flex flex-col gap-3">
{yourSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={() => handleDeleteClick(skill)}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="example-skills" className="mt-4">
{exampleSkills.length === 0 ? (
<SkillListItem
variant="placeholder"
message={
searchQuery
? t('agents.no-skills-found')
: t('agents.no-example-skills')
}
/>
) : (
<div className="flex flex-col gap-3">
{exampleSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={() => handleDeleteClick(skill)}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
{/* Upload Dialog */}
<SkillUploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
/>
{/* Delete Dialog */}
<SkillDeleteDialog
open={deleteDialogOpen}
skill={skillToDelete}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</div>
);
}

View file

@ -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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
<DialogContent size="sm" showCloseButton onClose={onCancel}>
<DialogHeader title={t('capabilities.delete-skill')} />
<DialogContentSection>
<p className="text-body-sm text-text-body">
{t('capabilities.delete-skill-confirmation', {
name: skill?.name || '',
})}
</p>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
cancelButtonText={t('layout.cancel')}
confirmButtonText={t('layout.delete')}
confirmButtonVariant="cuation"
onCancel={onCancel}
onConfirm={handleDelete}
/>
</DialogContent>
</Dialog>
<ConfirmModal
isOpen={open}
onClose={onCancel}
onConfirm={handleDelete}
title={t('agents.delete-skill')}
message={t('agents.delete-skill-confirmation', {
name: skill?.name || '',
})}
confirmText={t('layout.delete')}
cancelText={t('layout.cancel')}
confirmVariant="cuation"
/>
);
}

View file

@ -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 (
<div
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
className={`flex w-full flex-col flex-wrap items-center justify-center gap-3 rounded-2xl bg-surface-primary px-6 py-8 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isClickable ? 'cursor-pointer hover:bg-surface-tertiary' : ''}`}
onClick={isClickable ? props.onAddClick : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onAddClick?.();
}
}
: undefined
}
aria-label={isClickable ? props.addButtonText : undefined}
>
<p className="text-body-sm text-text-label">{props.message}</p>
{isClickable && <Plus className="h-4 w-4 text-icon-primary" />}
</div>
);
}
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 (
<div className="flex-1 w-full flex-col justify-between rounded-2xl bg-surface-tertiary p-4 transition-colors">
{/* Row 1: Name / Actions */}
<div className="flex items-center justify-between">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-body-base font-bold text-text-heading">
{skill.name}
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-md">
<span className="text-label-xs text-text-disabled">
{t('agents.added')} {formatDate(skill.addedAt)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4 text-icon-primary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={handleTryInChat}>
<MessageSquare className="h-4 w-4" />
{t('agents.try-in-chat')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
className="text-text-cuation focus:text-text-cuation"
>
<Trash2 className="h-4 w-4" />
{t('layout.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Row 2: Description full width / wrapped */}
<div className="flex-1 w-full max-h-[100px]">
<p className="text-body-sm text-text-label overflow-hidden text-ellipsis wrap">
{skill.description}
</p>
</div>
{/* Row 3: Added time / Skill scope */}
<div className="flex flex-col items-start gap-2">
<Button
variant="ghost"
size="sm"
className={`px-0 focus:ring-0 ${scopeOpen ? 'opacity-100' : 'opacity-50'}`}
onClick={() => setScopeOpen((prev) => !prev)}
>
Select agent access
<ChevronRight
className={`h-4 w-4 ${scopeOpen ? '-rotate-90' : ''}`}
/>
</Button>
{scopeOpen && (
<div className="w-full flex flex-wrap items-center gap-2 pt-4 border-t-[0.5px] border-x-0 border-b-0 border-solid border-border-secondary">
{/* All agents as first tab; then each agent toggle */}
<button
type="button"
onClick={handleToggleAllAgents}
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 ${
isAllAgentsSelected
? 'opacity-100 [&>svg]:text-icon-success'
: 'opacity-60 [&>svg]:text-inherit'
}`}
>
{isAllAgentsSelected ? (
<Check size={16} className="shrink-0" />
) : (
<Users size={16} className="shrink-0" />
)}
All Agents
</button>
{allAgents.map((agentName) => {
const isSelected = skill.scope.selectedAgents.includes(agentName);
const display = getWorkflowAgentDisplay(agentName);
const icon = display?.icon ?? (
<Bot size={16} className="shrink-0 text-inherit" />
);
return (
<button
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 ${
isSelected
? 'opacity-100 [&>svg]:text-icon-success'
: 'opacity-50 [&>svg]:text-inherit'
}`}
>
{isSelected ? <Check size={16} className="shrink-0" /> : icon}
{agentName}
</button>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -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<File | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [_isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent
size="sm"
showCloseButton
onClose={handleClose}
overlayVariant="dark"
>
<DialogHeader title={t('agents.add-skill')} />
<DialogContentSection>
<div className="flex flex-col gap-4">
{/* Drop Zone */}
<div
className={`relative cursor-pointer rounded-xl border-2 border-dashed p-8 transition-colors duration-300 ease-in ${
uploadError
? 'border-border-cuation bg-surface-cuation'
: isDragging
? 'border-border-focus bg-surface-tertiary'
: 'border-border-secondary hover:border-border-primary hover:bg-surface-secondary'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".skill,.md,.zip"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex flex-col items-center gap-6">
<div className="flex items-center gap-2">
<div
className={`flex p-1 flex-shrink-0 items-center justify-center rounded-lg ${
uploadError
? 'bg-surface-cuation'
: 'bg-surface-tertiary'
}`}
>
<File
className={`h-4 w-4 ${
uploadError
? 'text-icon-cuation'
: 'text-icon-primary'
}`}
/>
</div>
<div className="min-w-0 w-full flex flex-col">
<span
className={`truncate text-body-sm font-medium ${
uploadError
? 'text-text-cuation'
: 'text-text-heading'
}`}
>
{selectedFile.name}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<span
className={`text-label-sm ${
uploadError ? 'text-text-cuation' : 'text-text-label'
}`}
>
{uploadError
? t('agents.reupload-file')
: `${(selectedFile.size / 1024).toFixed(1)} KB`}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center">
<Upload className="h-6 w-6 text-icon-secondary" />
</div>
<div className="text-center flex flex-col items-center gap-1">
<span className="text-body-sm font-medium text-text-heading">
{t('agents.drag-and-drop')}
</span>
<span className="mt-1 text-label-sm text-text-label">
{t('agents.or-click-to-browse')}
</span>
</div>
</div>
)}
</div>
{/* Error notice */}
{uploadError && errorMessage && (
<div
className="flex items-center gap-4 rounded-xl border border-border-cuation bg-surface-cuation px-4 py-3"
role="alert"
>
<AlertCircle className="h-4 w-4 shrink-0 text-icon-cuation" />
<span className="text-label-sm text-text-cuation">
{errorMessage}
</span>
</div>
)}
{/* File Requirements */}
<div className="rounded-xl bg-surface-secondary p-4">
<span className="text-label-sm font-bold text-text-body">
{t('agents.file-requirements')}
</span>
<span className="mt-2 flex items-start gap-2 text-label-sm text-text-label">
<span className="text-text-label"></span>
<span>{t('agents.file-requirements-detail-1')}</span>
</span>
<span className="mt-1 flex items-start gap-2 text-label-sm text-text-label">
<span className="text-text-label"></span>
<span>{t('agents.file-requirements-detail-2')}</span>
</span>
</div>
</div>
</DialogContentSection>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<div className="m-auto flex h-auto max-w-[900px] flex-col px-4 py-4">
<div className="flex h-auto w-full px-3">
<div className="sticky top-20 flex !w-[222px] flex-shrink-0 flex-grow-0 flex-col self-start pr-4 pt-md">
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="flex h-auto w-full px-6">
<div className="sticky top-20 flex h-full w-40 flex-shrink-0 flex-grow-0 flex-col justify-between self-start pr-6 pt-8">
<VerticalNavigation
items={
menuItems.map((menu) => ({
value: menu.id,
label: (
<span className="text-sm font-bold leading-13 text-text-primary">
{menu.name}
</span>
<span className="text-body-sm font-bold">{menu.name}</span>
),
})) as VerticalNavItem[]
}
@ -68,7 +66,7 @@ export default function Capabilities() {
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="flex flex-col gap-4 py-md pb-md">
<div className="flex flex-col gap-4">
{activeTab === 'models' && <Models />}
{activeTab === 'skills' && <Skills />}
{activeTab === 'memory' && <Memory />}

View file

@ -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<Skill | null>(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 (
<div className="space-y-8">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('capabilities.skills')}
</div>
<div className="flex items-center gap-sm">
<SearchInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('capabilities.search-skills')}
/>
<Button
variant="outline"
size="sm"
onClick={() => setUploadDialogOpen(true)}
>
<Plus className="h-4 w-4" />
<span>{t('capabilities.add')}</span>
</Button>
</div>
</div>
{/* Your Skills Section */}
<div className="flex flex-col">
<div className="flex items-center justify-between py-2">
<span className="text-body-md font-bold text-text-body">
{t('capabilities.your-skills')}
</span>
<Button
variant="ghost"
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCollapsedYourSkills((c) => !c);
}}
>
{collapsedYourSkills ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!collapsedYourSkills && (
<>
{yourSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl bg-surface-secondary py-8 text-center">
<p className="text-body-sm text-text-label">
{searchQuery
? t('capabilities.no-skills-found')
: t('capabilities.no-your-skills')}
</p>
{!searchQuery && (
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setUploadDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t('capabilities.add-your-first-skill')}
</Button>
)}
</div>
) : (
<div className="flex flex-col">
{yourSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={() => handleDeleteClick(skill)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Example Skills Section */}
<div className="flex flex-col">
<div className="flex items-center justify-between py-2">
<span className="text-body-md font-bold text-text-body">
{t('capabilities.example-skills')}
</span>
<Button
variant="ghost"
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCollapsedExampleSkills((c) => !c);
}}
>
{collapsedExampleSkills ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!collapsedExampleSkills && (
<>
{exampleSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl bg-surface-secondary py-8 text-center">
<p className="text-body-sm text-text-label">
{searchQuery
? t('capabilities.no-skills-found')
: t('capabilities.no-example-skills')}
</p>
</div>
) : (
<div className="flex flex-col">
{exampleSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={() => handleDeleteClick(skill)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Upload Dialog */}
<SkillUploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
/>
{/* Delete Dialog */}
<SkillDeleteDialog
open={deleteDialogOpen}
skill={skillToDelete}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</div>
);
}

View file

@ -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 (
<div className="mb-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-secondary p-4">
{/* Left side: Status dot + Info */}
<div className="flex min-w-0 flex-1 items-center gap-xs">
{/* Status indicator dot */}
<div
className={`mx-xs h-3 w-3 flex-shrink-0 rounded-full ${
skill.enabled ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
{/* Name and description */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold leading-7 text-text-primary">
{skill.name}
</span>
<p className="truncate text-body-sm text-text-label">
{skill.description}
</p>
<span className="mt-1 text-label-sm text-text-disabled">
{t('capabilities.added')} {formatDate(skill.addedAt)}
</span>
</div>
</div>
{/* Right side: Controls */}
<div className="flex flex-shrink-0 items-center gap-2">
{/* Scope Select */}
<SkillScopeSelect
scope={skill.scope}
onChange={handleScopeChange}
disabled={!skill.enabled}
/>
{/* Enable/Disable Switch */}
<Switch checked={skill.enabled} onCheckedChange={handleToggle} />
{/* More Actions Menu (三个点) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Ellipsis className="h-4 w-4 text-icon-primary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={handleTryInChat}>
<MessageSquare className="h-4 w-4" />
{t('capabilities.try-in-chat')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
className="text-text-cuation focus:text-text-cuation"
>
<Trash2 className="h-4 w-4" />
{t('layout.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col gap-1">
<span className="text-label-xs text-text-label">
{t('capabilities.skill-scope')}
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="min-w-[120px] justify-between gap-2 text-text-body"
>
{hasSelection ? (
<Users className="h-4 w-4 text-icon-secondary" />
) : (
<Globe className="h-4 w-4 text-icon-secondary" />
)}
<span className="flex-1 truncate text-left">
{getDisplayText()}
</span>
<ChevronDown className="h-4 w-4 text-icon-secondary" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 rounded-xl border border-solid border-dropdown-border bg-dropdown-bg p-sm"
>
<div className="flex flex-col gap-1">
{/* Global Option - same level as agents */}
<button
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-body-sm transition-colors ${
scope.isGlobal
? 'bg-dropdown-item-bg-active text-text-heading'
: 'text-text-body hover:bg-dropdown-item-bg-hover'
}`}
onClick={() => handleToggle(GLOBAL_OPTION)}
>
<Globe className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">{t('capabilities.global')}</span>
{scope.isGlobal && <Check className="h-4 w-4 flex-shrink-0" />}
</button>
{/* Agent/Worker List - Multi-select, same level as Global */}
{allAgents.map((agentName) => {
const isSelected = scope.selectedAgents.includes(agentName);
return (
<button
key={agentName}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-body-sm transition-colors ${
isSelected
? 'bg-dropdown-item-bg-active text-text-heading'
: 'text-text-body hover:bg-dropdown-item-bg-hover'
}`}
onClick={() => handleToggle(agentName)}
>
<span className="flex-1 truncate">{agentName}</span>
{isSelected && <Check className="h-4 w-4 flex-shrink-0" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
);
}

View file

@ -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<File | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent size="sm" showCloseButton onClose={handleClose}>
<DialogHeader title={t('capabilities.add-skill')} />
<DialogContentSection>
<div className="flex flex-col gap-4">
{/* Drop Zone */}
<div
className={`relative cursor-pointer rounded-xl border-2 border-dashed p-8 transition-colors ${
isDragging
? 'border-border-focus bg-surface-tertiary'
: 'border-border-secondary hover:border-border-primary'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".skill,.md,.txt,.json,.zip"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-surface-tertiary">
<File className="h-5 w-5 text-icon-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-text-heading">
{selectedFile.name}
</p>
<p className="text-label-sm text-text-label">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-surface-tertiary">
<Upload className="h-6 w-6 text-icon-secondary" />
</div>
<div className="text-center">
<p className="text-body-sm font-medium text-text-heading">
{t('capabilities.drag-and-drop')}
</p>
<p className="mt-1 text-label-sm text-text-label">
{t('capabilities.or-click-to-browse')}
</p>
</div>
</div>
)}
</div>
{/* File Requirements */}
<div className="rounded-xl bg-surface-secondary p-4">
<p className="text-label-sm font-bold text-text-body">
{t('capabilities.file-requirements')}
</p>
<ul className="mt-2 space-y-1">
<li className="flex items-start gap-2 text-label-sm text-text-label">
<span className="text-text-label"></span>
<span>{t('capabilities.file-requirements-detail-1')}</span>
</li>
<li className="flex items-start gap-2 text-label-sm text-text-label">
<span className="text-text-label"></span>
<span>{t('capabilities.file-requirements-detail-2')}</span>
</li>
</ul>
</div>
</div>
</DialogContentSection>
<DialogFooter
showCancelButton
showConfirmButton
cancelButtonText={t('layout.cancel')}
confirmButtonText={t('capabilities.upload')}
onCancel={handleClose}
onConfirm={handleUpload}
confirmButtonDisabled={!selectedFile || isUploading}
/>
</DialogContent>
</Dialog>
);
}

View file

@ -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() {
</MenuToggleItem>
<MenuToggleItem
size="xs"
value="capabilities"
value="agents"
iconAnimateOnHover="default"
icon={<Layers className="h-4 w-4" />}
icon={<Bot className="h-4 w-4" />}
>
{t('layout.capabilities')}
{t('setting.agents')}
</MenuToggleItem>
<MenuToggleItem
size="xs"
@ -198,7 +199,7 @@ export default function Home() {
{activeTab === 'mcp_tools' && <MCP />}
{activeTab === 'browser' && <Browser />}
{activeTab === 'settings' && <Setting />}
{activeTab === 'capabilities' && <Capabilities />}
{activeTab === 'agents' && <Agents />}
</div>
);
}

View file

@ -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);
});
});

View file

@ -20,10 +20,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';
@ -49,12 +48,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 = () => {
@ -131,7 +124,6 @@ export default function Setting() {
<div className="flex flex-col gap-4">
{activeTab === 'general' && <General />}
{activeTab === 'privacy' && <Privacy />}
{activeTab === 'models' && <Models />}
</div>
</div>
</div>