mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
fix(redesign): redesign hermes skills add flow
This commit is contained in:
@@ -8,9 +8,11 @@ import {
|
|||||||
FileUp,
|
FileUp,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -24,6 +26,9 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Input,
|
Input,
|
||||||
} from '../../../shared/view/ui';
|
} from '../../../shared/view/ui';
|
||||||
import { useProviderSkills } from '../hooks/useProviderSkills';
|
import { useProviderSkills } from '../hooks/useProviderSkills';
|
||||||
@@ -248,6 +253,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [registryQuery, setRegistryQuery] = useState('');
|
const [registryQuery, setRegistryQuery] = useState('');
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [addMode, setAddMode] = useState<'upload' | 'hub'>('upload');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -259,6 +266,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
setRegistryQuery('');
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
setAddMode('upload');
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -386,6 +396,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
})));
|
})));
|
||||||
await addSkills({ entries });
|
await addSkills({ entries });
|
||||||
setQueuedFiles([]);
|
setQueuedFiles([]);
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -393,6 +404,231 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
}
|
}
|
||||||
}, [addSkills, queuedFiles]);
|
}, [addSkills, queuedFiles]);
|
||||||
|
|
||||||
|
const uploadPanel = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
||||||
|
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-dashed p-4 transition-colors sm:p-5',
|
||||||
|
isDragActive
|
||||||
|
? 'border-foreground/40 bg-muted/35'
|
||||||
|
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,text/markdown"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleDrop(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleFolderSelection(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
|
||||||
|
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Upload standalone definitions or choose a full folder to include scripts, references, and assets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
Choose Files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FolderUp className="h-4 w-4" />
|
||||||
|
Choose Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queuedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{queuedFiles.map((queuedFile) => (
|
||||||
|
<div
|
||||||
|
key={queuedFile.id}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{queuedFile.kind === 'folder'
|
||||||
|
? `${queuedFile.files.length} files`
|
||||||
|
: 'Markdown file'}
|
||||||
|
{' · '}
|
||||||
|
{formatFileSize(queuedFile.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleUploadInstall()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hermesHubPanel = selectedProvider === 'hermes' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={registryQuery}
|
||||||
|
onChange={(event) => setRegistryQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
void searchRegistry(registryQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search Hermes skills..."
|
||||||
|
aria-label="Search Hermes skills registry"
|
||||||
|
className="h-9 w-full pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
|
||||||
|
onClick={() => void searchRegistry(registryQuery)}
|
||||||
|
>
|
||||||
|
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
<Wrench className="h-3.5 w-3.5" />
|
||||||
|
Hub Maintenance
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{HERMES_SKILL_ACTIONS.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.action}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
title={action.description}
|
||||||
|
disabled={registryBusyKey === action.action}
|
||||||
|
onClick={() => void runRegistryMaintenance(action.action)}
|
||||||
|
>
|
||||||
|
{registryBusyKey === action.action
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Icon className="h-4 w-4" />}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{registryResults.length > 0 && (
|
||||||
|
<div className="grid max-h-[320px] gap-2 overflow-y-auto pr-1">
|
||||||
|
{registryResults.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.identifier}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{result.name}</span>
|
||||||
|
{result.source && (
|
||||||
|
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
|
||||||
|
)}
|
||||||
|
{result.trustLevel && (
|
||||||
|
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
|
||||||
|
{result.description && (
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={registryBusyKey === `install:${result.identifier}`}
|
||||||
|
onClick={() => void installRegistrySkill(result.identifier)}
|
||||||
|
>
|
||||||
|
{registryBusyKey === `install:${result.identifier}`
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Upload className="h-4 w-4" />}
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
@@ -408,279 +644,105 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
onClick={() => void refreshSkills({ force: true })}
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={isLoading || isLoadingProjectScopes}
|
onClick={() => {
|
||||||
>
|
setAddMode('upload');
|
||||||
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
setIsAddDialogOpen(true);
|
||||||
Refresh
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Skill
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void refreshSkills({ force: true })}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={isLoading || isLoadingProjectScopes}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedProvider === 'hermes' && (
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<div className="rounded-lg border border-border/70 bg-muted/15 p-3">
|
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto p-0">
|
||||||
<div className="mb-3 flex min-w-0 flex-col gap-1">
|
<DialogTitle>Add Skill</DialogTitle>
|
||||||
<div className="min-w-0">
|
<div className="border-b border-border/60 px-4 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div className="flex items-start gap-3">
|
||||||
<Compass className="h-4 w-4" />
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||||
Hermes Skills Hub
|
{addMode === 'hub' ? <Compass className="h-4 w-4" /> : <FileUp className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="min-w-0 flex-1">
|
||||||
Search the Hermes registry, install skills, and keep installed hub skills current.
|
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{selectedProvider === 'hermes'
|
||||||
|
? 'Upload a local skill or install one from the Hermes Skills Hub.'
|
||||||
|
: 'Upload a markdown skill file or a complete skill folder.'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<div className="relative min-w-0 flex-1">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={registryQuery}
|
|
||||||
onChange={(event) => setRegistryQuery(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
void searchRegistry(registryQuery);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Search Hermes skills..."
|
|
||||||
aria-label="Search Hermes skills registry"
|
|
||||||
className="h-9 w-full pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
|
|
||||||
onClick={() => void searchRegistry(registryQuery)}
|
|
||||||
>
|
|
||||||
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
|
||||||
Search
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid gap-2 md:grid-cols-3">
|
{selectedProvider === 'hermes' && (
|
||||||
{HERMES_SKILL_ACTIONS.map((action) => {
|
<div className="mt-4 inline-flex rounded-lg border border-border/70 bg-muted/20 p-1">
|
||||||
const Icon = action.icon;
|
<button
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={action.action}
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
className={cn(
|
||||||
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
|
'inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors',
|
||||||
disabled={registryBusyKey === action.action}
|
addMode === 'upload'
|
||||||
onClick={() => void runRegistryMaintenance(action.action)}
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={() => setAddMode('upload')}
|
||||||
>
|
>
|
||||||
{registryBusyKey === action.action
|
<FileUp className="h-4 w-4" />
|
||||||
? <Loader2 className="h-4 w-4 flex-shrink-0 animate-spin" />
|
Upload
|
||||||
: <Icon className="h-4 w-4 flex-shrink-0" />}
|
</button>
|
||||||
<span className="min-w-0">
|
<button
|
||||||
<span className="block text-sm font-medium text-foreground">{action.label}</span>
|
type="button"
|
||||||
<span className="block text-xs text-muted-foreground">{action.description}</span>
|
className={cn(
|
||||||
</span>
|
'inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors',
|
||||||
</Button>
|
addMode === 'hub'
|
||||||
);
|
? 'bg-background text-foreground shadow-sm'
|
||||||
})}
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
</div>
|
)}
|
||||||
|
onClick={() => setAddMode('hub')}
|
||||||
{(registryError || registryStatus) && (
|
|
||||||
<div className={cn(
|
|
||||||
'mt-3 rounded-lg border px-3 py-2 text-xs',
|
|
||||||
registryError
|
|
||||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
|
|
||||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
|
||||||
)}>
|
|
||||||
{registryError || registryStatus}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{registryResults.length > 0 && (
|
|
||||||
<div className="mt-3 grid gap-2">
|
|
||||||
{registryResults.map((result) => (
|
|
||||||
<div
|
|
||||||
key={result.identifier}
|
|
||||||
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<Compass className="h-4 w-4" />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
Skills Hub
|
||||||
<span className="text-sm font-medium text-foreground">{result.name}</span>
|
</button>
|
||||||
{result.source && (
|
|
||||||
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
|
|
||||||
)}
|
|
||||||
{result.trustLevel && (
|
|
||||||
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
|
|
||||||
{result.description && (
|
|
||||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
disabled={registryBusyKey === `install:${result.identifier}`}
|
|
||||||
onClick={() => void installRegistrySkill(result.identifier)}
|
|
||||||
>
|
|
||||||
{registryBusyKey === `install:${result.identifier}`
|
|
||||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
: <Upload className="h-4 w-4" />}
|
|
||||||
Install
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
|
|
||||||
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
||||||
<div className="text-sm font-medium text-foreground">Upload Skills</div>
|
|
||||||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
|
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
|
||||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4 p-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div
|
|
||||||
{...getRootProps()}
|
|
||||||
className={cn(
|
|
||||||
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
|
|
||||||
isDragActive
|
|
||||||
? 'border-foreground/40 bg-muted/35'
|
|
||||||
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".md,text/markdown"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleDrop(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={folderInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleFolderSelection(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
|
|
||||||
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<FileUp className="h-4 w-4" />
|
|
||||||
Choose Files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<FolderUp className="h-4 w-4" />
|
|
||||||
Choose Folder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{queuedFiles.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{queuedFiles.map((queuedFile) => (
|
|
||||||
<div
|
|
||||||
key={queuedFile.id}
|
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{queuedFile.kind === 'folder'
|
|
||||||
? `${queuedFile.files.length} files`
|
|
||||||
: 'Markdown file'}
|
|
||||||
{' · '}
|
|
||||||
{formatFileSize(queuedFile.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUploadInstall()}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
||||||
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(submitError || loadError) && (
|
<div className="space-y-4 p-4">
|
||||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
{addMode === 'hub' && hermesHubPanel ? hermesHubPanel : uploadPanel}
|
||||||
{submitError || loadError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{saveStatus === 'success' && (
|
{(submitError || loadError || registryError || registryStatus || saveStatus === 'success') && (
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
<div className={cn(
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
'rounded-lg border px-3 py-2 text-sm',
|
||||||
Skills saved successfully.
|
submitError || loadError || registryError
|
||||||
</div>
|
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
|
||||||
)}
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||||
</CardContent>
|
)}>
|
||||||
</Card>
|
{submitError || loadError || registryError || registryStatus || 'Skills saved successfully.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{saveStatus === 'success' && !isAddDialogOpen && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Skills saved successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
||||||
<CardHeader className="border-b border-border/60">
|
<CardHeader className="border-b border-border/60">
|
||||||
|
|||||||
Reference in New Issue
Block a user