mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-06-01 14:29:18 +00:00
update skill ui
This commit is contained in:
parent
b2515ed9cd
commit
9318dd4e43
46 changed files with 1407 additions and 1026 deletions
|
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
106
src/components/WorkFlow/agents.tsx
Normal file
106
src/components/WorkFlow/agents.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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": "مهارة مخصصة",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "カスタムスキル",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "사용자 정의 스킬",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "Пользовательский навык",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "自定义技能",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "自訂技能",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
192
src/pages/Agents/Skills.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
278
src/pages/Agents/components/SkillListItem.tsx
Normal file
278
src/pages/Agents/components/SkillListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
src/pages/Agents/components/SkillUploadDialog.tsx
Normal file
371
src/pages/Agents/components/SkillUploadDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue