feat(web): improve skill import flow

This commit is contained in:
Junyan Qin
2026-05-18 18:33:39 +08:00
parent 971cc3f675
commit 9e62227104
11 changed files with 1270 additions and 133 deletions
+8 -50
View File
@@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -45,12 +46,10 @@ import type {
MCPFormDraft,
MCPFormHandle,
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import type { SkillFormDraft } from '@/app/home/skills/components/skill-form/SkillForm';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
type PopoverView = 'menu' | 'mcp' | 'skill' | 'github';
type PopoverView = 'menu' | 'mcp' | 'github';
enum GithubInstallStatus {
WAIT_INPUT = 'wait_input',
@@ -151,6 +150,7 @@ export default function AddExtensionPage() {
function AddExtensionContent() {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const {
addTask,
@@ -179,7 +179,6 @@ function AddExtensionContent() {
const mcpFormRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false);
const [mcpDraft, setMcpDraft] = useState<MCPFormDraft | undefined>();
const [skillDraft, setSkillDraft] = useState<SkillFormDraft | undefined>();
// GitHub install state
const [githubURL, setGithubURL] = useState('');
@@ -374,14 +373,6 @@ function AddExtensionContent() {
setPopoverOpen(false);
}
function handleSkillCreated(_skillName: string) {
setSkillDraft(undefined);
refreshPlugins();
refreshSkills();
setPopoverView('menu');
setPopoverOpen(false);
}
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
@@ -614,8 +605,6 @@ function AddExtensionContent() {
switch (popoverView) {
case 'mcp':
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'skill':
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
case 'github':
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
default:
@@ -729,7 +718,11 @@ function AddExtensionContent() {
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('skill')}
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setPopoverOpen(false);
navigate('/home/skills?action=create');
}}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
<BookOpen className="size-4" />
@@ -803,41 +796,6 @@ function AddExtensionContent() {
</div>
)}
{/* ===== Skill Form View ===== */}
{popoverView === 'skill' && (
<div className="flex max-h-[min(720px,80vh)] flex-col">
<div className="flex items-center gap-2 px-4 pb-1 pt-3">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setPopoverView('menu')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h4 className="text-sm font-medium leading-none">
{t('skills.createSkill')}
</h4>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<SkillForm
initSkillName={undefined}
initialDraft={skillDraft}
onNewSkillCreated={handleSkillCreated}
onSkillUpdated={() => {}}
onDraftChange={setSkillDraft}
/>
</div>
<div className="flex items-center justify-end gap-2 bg-popover px-4 pb-4 pt-1">
<Button type="submit" form="skill-form" size="sm">
{t('common.save')}
</Button>
</div>
</div>
)}
{/* ===== GitHub Install View ===== */}
{popoverView === 'github' && (
<div className="flex max-h-[min(720px,80vh)] flex-col">
@@ -15,12 +15,13 @@ import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
FolderSearch,
ChevronDown,
ChevronRight,
File,
FileIcon,
Folder,
FolderOpen,
FolderUp,
Loader2,
RefreshCw,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -50,6 +51,209 @@ interface FileEntry {
size: number | null;
}
interface PreviewSkill extends Skill {
source_path?: string;
entry_file?: string;
}
type DirectoryFile = File & {
webkitRelativePath?: string;
};
interface DirectoryTreeNode {
name: string;
path: string;
is_dir: boolean;
size: number | null;
children: DirectoryTreeNode[];
}
const CRC32_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let value = i;
for (let j = 0; j < 8; j += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
CRC32_TABLE[i] = value >>> 0;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function dosDateTime(timestamp: number) {
const date = new Date(timestamp || Date.now());
const year = Math.max(date.getFullYear(), 1980);
return {
time:
(date.getHours() << 11) |
(date.getMinutes() << 5) |
Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
function concatUint8Arrays(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
async function createStoredZip(
entries: Array<{ name: string; file: File }>,
): Promise<Blob> {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
for (const entry of entries) {
const nameBytes = encoder.encode(entry.name);
const fileBytes = new Uint8Array(await entry.file.arrayBuffer());
const checksum = crc32(fileBytes);
const dateTime = dosDateTime(entry.file.lastModified);
const localHeader = new Uint8Array(30 + nameBytes.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0);
writeUint16(localHeader, 8, 0);
writeUint16(localHeader, 10, dateTime.time);
writeUint16(localHeader, 12, dateTime.date);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, fileBytes.length);
writeUint32(localHeader, 22, fileBytes.length);
writeUint16(localHeader, 26, nameBytes.length);
writeUint16(localHeader, 28, 0);
localHeader.set(nameBytes, 30);
localChunks.push(localHeader, fileBytes);
const centralHeader = new Uint8Array(46 + nameBytes.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0);
writeUint16(centralHeader, 10, 0);
writeUint16(centralHeader, 12, dateTime.time);
writeUint16(centralHeader, 14, dateTime.date);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, fileBytes.length);
writeUint32(centralHeader, 24, fileBytes.length);
writeUint16(centralHeader, 28, nameBytes.length);
writeUint16(centralHeader, 30, 0);
writeUint16(centralHeader, 32, 0);
writeUint16(centralHeader, 34, 0);
writeUint16(centralHeader, 36, 0);
writeUint32(centralHeader, 38, 0);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(nameBytes, 46);
centralChunks.push(centralHeader);
localOffset += localHeader.length + fileBytes.length;
}
const centralDirectory = concatUint8Arrays(centralChunks);
const endRecord = new Uint8Array(22);
writeUint32(endRecord, 0, 0x06054b50);
writeUint16(endRecord, 4, 0);
writeUint16(endRecord, 6, 0);
writeUint16(endRecord, 8, entries.length);
writeUint16(endRecord, 10, entries.length);
writeUint32(endRecord, 12, centralDirectory.length);
writeUint32(endRecord, 16, localOffset);
writeUint16(endRecord, 20, 0);
return new Blob([...localChunks, centralDirectory, endRecord] as BlobPart[], {
type: 'application/zip',
});
}
function pathDirname(path: string) {
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const index = normalized.lastIndexOf('/');
return index >= 0 ? normalized.slice(0, index) : '';
}
function pathBasename(path: string) {
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
return normalized.split('/').pop() || normalized;
}
function buildDirectoryTree(
entries: Array<{ path: string; size: number | null }>,
): DirectoryTreeNode[] {
const root: DirectoryTreeNode = {
name: '',
path: '',
is_dir: true,
size: null,
children: [],
};
for (const entry of entries) {
const path = entry.path;
const parts = path.split('/').filter(Boolean);
let current = root;
parts.forEach((part, index) => {
const isLast = index === parts.length - 1;
const nodePath = parts.slice(0, index + 1).join('/');
let node = current.children.find((child) => child.name === part);
if (!node) {
node = {
name: part,
path: nodePath,
is_dir: !isLast,
size: null,
children: [],
};
current.children.push(node);
}
if (!isLast) {
node.is_dir = true;
}
if (isLast) {
node.size = entry.size;
}
current = node;
});
}
function sortNodes(nodes: DirectoryTreeNode[]) {
nodes.sort((a, b) => {
if (a.is_dir !== b.is_dir) {
return a.is_dir ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
nodes.forEach((node) => sortNodes(node.children));
}
sortNodes(root.children);
return root.children;
}
interface FileTreeProps {
skillName: string;
selectedFile?: string | null;
@@ -190,7 +394,7 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
)}
</>
) : (
<File className="h-4 w-4 text-gray-500" />
<FileIcon className="h-4 w-4 text-gray-500" />
)}
<span className="text-sm truncate">{entry.name}</span>
{!entry.is_dir && entry.size !== null && (
@@ -226,6 +430,125 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
);
});
interface LocalFileTreeProps {
entries: DirectoryTreeNode[];
fileMap: Map<string, File>;
selectedFile?: string | null;
onFileSelect: (path: string, content: string) => void;
}
function collectDirectoryPaths(nodes: DirectoryTreeNode[]): string[] {
return nodes.flatMap((node) =>
node.is_dir ? [node.path, ...collectDirectoryPaths(node.children)] : [],
);
}
function LocalFileTree({
entries,
fileMap,
selectedFile,
onFileSelect,
}: LocalFileTreeProps) {
const { t } = useTranslation();
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
setSelectedPath(selectedFile ?? null);
}, [selectedFile]);
useEffect(() => {
setExpandedDirs(new Set(collectDirectoryPaths(entries)));
}, [entries]);
const toggleDir = (dirPath: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
}
return next;
});
};
const handleFileClick = async (filePath: string) => {
const file = fileMap.get(filePath);
if (!file) return;
setSelectedPath(filePath);
try {
onFileSelect(filePath, await file.text());
} catch (error) {
console.error('Failed to read local file:', error);
toast.error(t('skills.readFileError') + String(error));
}
};
const renderEntry = (entry: DirectoryTreeNode, depth: number = 0) => {
const isExpanded = expandedDirs.has(entry.path);
const isSelected = selectedPath === entry.path;
return (
<div key={entry.path}>
<div
className={`flex items-center gap-1 rounded px-2 py-1 hover:bg-muted ${
isSelected ? 'bg-muted' : ''
} cursor-pointer`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() =>
entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)
}
>
{entry.is_dir ? (
<>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-blue-500" />
) : (
<Folder className="h-4 w-4 text-blue-500" />
)}
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</>
) : (
<FileIcon className="h-4 w-4 text-gray-500" />
)}
<span className="truncate text-sm">{entry.name}</span>
{!entry.is_dir && entry.size !== null && (
<span className="ml-auto text-xs text-muted-foreground">
{entry.size > 1024
? `${Math.round(entry.size / 1024)}KB`
: `${entry.size}B`}
</span>
)}
</div>
{entry.is_dir && isExpanded && (
<div>
{entry.children.map((child) => renderEntry(child, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="space-y-2">
<div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
{entries.length === 0 && (
<div className="py-2 text-sm text-muted-foreground">
{t('skills.noFiles')}
</div>
)}
{entries.map((entry) => renderEntry(entry))}
</div>
</div>
);
}
const emptySkillDraft: SkillFormDraft = {
skill: {
name: '',
@@ -251,13 +574,24 @@ export default function SkillForm({
const [skill, setSkill] = useState<Partial<Skill>>(
initialDraftRef.current.skill,
);
const [scanning, setScanning] = useState(false);
const [importingDirectory, setImportingDirectory] = useState(false);
const [installingDirectory, setInstallingDirectory] = useState(false);
const [directoryZipFile, setDirectoryZipFile] = useState<File | null>(null);
const [directoryPreview, setDirectoryPreview] = useState<PreviewSkill | null>(
null,
);
const [directorySourceName, setDirectorySourceName] = useState('');
const [directoryTree, setDirectoryTree] = useState<DirectoryTreeNode[]>([]);
const [directoryFileMap, setDirectoryFileMap] = useState<Map<string, File>>(
new Map(),
);
const [showAdvanced, setShowAdvanced] = useState(
initialDraftRef.current.showAdvanced,
);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const fileTreeRef = useRef<FileTreeHandle>(null);
const directoryInputRef = useRef<HTMLInputElement>(null);
const [fileTreeLoading, setFileTreeLoading] = useState(false);
const loadSkill = useCallback(
@@ -275,6 +609,11 @@ export default function SkillForm({
[t],
);
useEffect(() => {
directoryInputRef.current?.setAttribute('webkitdirectory', '');
directoryInputRef.current?.setAttribute('directory', '');
}, []);
useEffect(() => {
if (initSkillName) {
loadSkill(initSkillName);
@@ -283,6 +622,11 @@ export default function SkillForm({
setSelectedFile(initialDraftRef.current.selectedFile ?? null);
setSkill(initialDraftRef.current.skill);
setShowAdvanced(initialDraftRef.current.showAdvanced);
setDirectoryZipFile(null);
setDirectoryPreview(null);
setDirectorySourceName('');
setDirectoryTree([]);
setDirectoryFileMap(new Map());
}, [initSkillName, loadSkill]);
useEffect(() => {
@@ -294,30 +638,130 @@ export default function SkillForm({
});
}, [initSkillName, onDraftChange, skill, showAdvanced, selectedFile]);
async function scanDirectory() {
const path = skill.package_root?.trim();
if (!path) {
toast.error(t('skills.packageRootRequired'));
async function handleDirectoryImport(
event: React.ChangeEvent<HTMLInputElement>,
) {
const files = Array.from(event.target.files ?? []) as DirectoryFile[];
event.target.value = '';
if (files.length === 0) {
return;
}
setScanning(true);
const skillMdFiles = files.filter((file) => {
const relativePath = file.webkitRelativePath || file.name;
return pathBasename(relativePath).toLowerCase() === 'skill.md';
});
if (skillMdFiles.length === 0) {
toast.error(t('skills.noSkillMdInDirectory'));
return;
}
if (skillMdFiles.length > 1) {
toast.error(t('skills.multipleSkillMdInDirectory'));
return;
}
const skillMdRelativePath =
skillMdFiles[0].webkitRelativePath || skillMdFiles[0].name;
const skillDir = pathDirname(skillMdRelativePath);
const packageName = pathBasename(
skillDir || pathDirname(files[0].webkitRelativePath || '') || 'skill',
);
const prefix = skillDir ? `${skillDir}/` : '';
const selectedFiles = files
.map((file) => {
const relativePath = file.webkitRelativePath || file.name;
if (prefix && !relativePath.startsWith(prefix)) {
return null;
}
const pathInPackage = prefix
? relativePath.slice(prefix.length)
: relativePath;
if (!pathInPackage || pathInPackage.endsWith('/')) {
return null;
}
return {
path: pathInPackage.replace(/^\/+/, ''),
file,
};
})
.filter((entry): entry is { path: string; file: File } => Boolean(entry));
const packageFiles = selectedFiles.map((entry) => ({
name: `${packageName}/${entry.path}`,
file: entry.file,
}));
setImportingDirectory(true);
try {
const result = await httpClient.scanSkillDirectory(path);
setSkill((prev) => ({
...prev,
name: prev.name || result.name,
display_name: prev.display_name || result.display_name || '',
description: prev.description || result.description,
package_root: result.package_root,
instructions: result.instructions,
}));
setFileContent(result.instructions);
toast.success(t('skills.scanSuccess'));
const zipBlob = await createStoredZip(packageFiles);
const zipFile = new File([zipBlob], `${packageName}.zip`, {
type: 'application/zip',
});
const resp = await httpClient.previewSkillInstallFromUpload(zipFile);
const preview = (resp.skills?.[0] ?? null) as PreviewSkill | null;
if (!preview) {
toast.error(t('skills.noSkillMdInDirectory'));
return;
}
setDirectoryZipFile(zipFile);
setDirectoryPreview(preview);
setDirectorySourceName(packageName);
setDirectoryTree(
buildDirectoryTree(
selectedFiles.map((entry) => ({
path: entry.path,
size: entry.file.size,
})),
),
);
setDirectoryFileMap(
new Map(selectedFiles.map((entry) => [entry.path, entry.file])),
);
setSelectedFile(preview.entry_file || 'SKILL.md');
setFileContent(preview.instructions || '');
setSkill({
name: preview.name || packageName,
display_name: preview.display_name || '',
description: preview.description || '',
instructions: preview.instructions || '',
package_root: preview.package_root || '',
});
} catch (error) {
console.error('Failed to scan directory:', error);
toast.error(t('skills.scanError') + String(error));
console.error('Failed to import local skill directory:', error);
toast.error(t('skills.importDirectoryError') + String(error));
} finally {
setScanning(false);
setImportingDirectory(false);
}
}
function clearDirectoryPreview() {
setDirectoryZipFile(null);
setDirectoryPreview(null);
setDirectorySourceName('');
setDirectoryTree([]);
setDirectoryFileMap(new Map());
setSelectedFile(null);
setFileContent('');
setSkill({ ...emptySkillDraft.skill });
}
async function handleDirectoryImportConfirm() {
if (!directoryZipFile) {
return;
}
setInstallingDirectory(true);
try {
const resp = await httpClient.installSkillFromUpload(directoryZipFile);
toast.success(t('skills.installSuccess'));
onNewSkillCreated(
resp.skills[0]?.name || directoryPreview?.name || directorySourceName,
);
} catch (error) {
console.error('Failed to install local skill directory:', error);
toast.error(t('skills.importDirectoryError') + String(error));
} finally {
setInstallingDirectory(false);
}
}
@@ -338,6 +782,11 @@ export default function SkillForm({
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!initSkillName && directoryZipFile) {
await handleDirectoryImportConfirm();
return;
}
if (!skill.name?.trim()) {
toast.error(t('skills.skillNameRequired'));
return;
@@ -385,6 +834,7 @@ export default function SkillForm({
value={skill.display_name || ''}
onChange={(e) => setSkill({ ...skill, display_name: e.target.value })}
placeholder={t('skills.displayNamePlaceholder')}
disabled={Boolean(directoryPreview)}
/>
</div>
@@ -401,7 +851,7 @@ export default function SkillForm({
}
placeholder={t('skills.skillSlugPlaceholder')}
className="font-mono"
disabled={Boolean(initSkillName)}
disabled={Boolean(initSkillName || directoryPreview)}
/>
<p className="text-xs text-muted-foreground">
{t('skills.skillSlugHelp')}
@@ -416,6 +866,7 @@ export default function SkillForm({
onChange={(e) => setSkill({ ...skill, description: e.target.value })}
placeholder={t('skills.descriptionPlaceholder')}
rows={3}
disabled={Boolean(directoryPreview)}
/>
</div>
</>
@@ -423,7 +874,7 @@ export default function SkillForm({
const fileTreeSection = (
<>
{initSkillName && (
{initSkillName ? (
<div className="space-y-2">
<FileTree
skillName={initSkillName}
@@ -431,6 +882,17 @@ export default function SkillForm({
onFileSelect={handleFileSelect}
/>
</div>
) : (
directoryPreview && (
<div className="space-y-2">
<LocalFileTree
entries={directoryTree}
fileMap={directoryFileMap}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
/>
</div>
)
)}
</>
);
@@ -448,7 +910,7 @@ export default function SkillForm({
id="instructions"
value={fileContent}
onChange={(e) => handleInstructionDraftChange(e.target.value)}
readOnly={Boolean(initSkillName)}
readOnly={Boolean(initSkillName || directoryPreview)}
placeholder={t('skills.instructionsPlaceholder')}
rows={16}
className="min-h-[360px] resize-y font-mono text-sm read-only:cursor-default read-only:bg-muted/30 lg:min-h-[calc(100vh-220px)]"
@@ -456,34 +918,44 @@ export default function SkillForm({
</div>
);
const advancedSettings = (
<div className="space-y-2">
<Label>{t('skills.packageRoot')}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={skill.package_root || ''}
onChange={(e) => setSkill({ ...skill, package_root: e.target.value })}
placeholder={`data/skills/${skill.name || '<skill-name>'}/`}
className="flex-1"
disabled={Boolean(initSkillName)}
/>
const localDirectoryImport = (
<div className="flex flex-col gap-2 sm:flex-row">
<input
ref={directoryInputRef}
type="file"
multiple
className="hidden"
onChange={handleDirectoryImport}
/>
<Button
type="button"
variant={directoryPreview ? 'secondary' : 'outline'}
onClick={() => directoryInputRef.current?.click()}
disabled={importingDirectory || installingDirectory}
className="w-full shrink-0 sm:w-auto"
>
{importingDirectory ? (
<Loader2 className="mr-1.5 size-4 animate-spin" />
) : (
<FolderUp className="mr-1.5 size-4" />
)}
{importingDirectory
? t('skills.importingDirectory')
: directoryPreview
? t('skills.chooseAnotherDirectory')
: t('skills.chooseSkillDirectory')}
</Button>
{directoryPreview && (
<Button
type="button"
variant="outline"
size="sm"
onClick={scanDirectory}
disabled={
Boolean(initSkillName) || scanning || !skill.package_root?.trim()
}
className="shrink-0"
variant="ghost"
onClick={clearDirectoryPreview}
disabled={installingDirectory}
className="w-full shrink-0 sm:w-auto"
>
<FolderSearch className="mr-1 h-4 w-4" />
{scanning ? t('common.loading') : t('skills.scan')}
{t('skills.clearDirectoryPreview')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('skills.packageRootHelp')}
</p>
)}
</div>
);
@@ -495,46 +967,59 @@ export default function SkillForm({
className="flex h-full min-h-0 max-w-full flex-col gap-6 overflow-y-auto lg:flex-row lg:overflow-hidden"
>
<div className="space-y-4 pb-6 lg:min-h-0 lg:w-[360px] lg:flex-shrink-0 lg:overflow-y-auto lg:overflow-x-hidden xl:w-[400px]">
{!initSkillName && (
<Card>
<CardHeader>
<CardTitle>{t('skills.importLocalDirectory')}</CardTitle>
</CardHeader>
<CardContent>{localDirectoryImport}</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{metadataFields}</CardContent>
</Card>
{initSkillName && (
{(initSkillName || directoryPreview) && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>{t('skills.files')}</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileTreeRef.current?.refresh()}
disabled={fileTreeLoading}
className="size-8"
>
<RefreshCw
className={`h-4 w-4 ${fileTreeLoading ? 'animate-spin' : ''}`}
/>
</Button>
{initSkillName && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileTreeRef.current?.refresh()}
disabled={fileTreeLoading}
className="size-8"
>
<RefreshCw
className={`h-4 w-4 ${fileTreeLoading ? 'animate-spin' : ''}`}
/>
</Button>
)}
</CardHeader>
<CardContent>
<FileTree
ref={fileTreeRef}
skillName={initSkillName}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
onLoadingChange={setFileTreeLoading}
/>
{initSkillName ? (
<FileTree
ref={fileTreeRef}
skillName={initSkillName}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
onLoadingChange={setFileTreeLoading}
/>
) : (
<LocalFileTree
entries={directoryTree}
fileMap={directoryFileMap}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
/>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t('skills.advancedSettings')}</CardTitle>
</CardHeader>
<CardContent>{advancedSettings}</CardContent>
</Card>
{sideFooter}
</div>
<div className="hidden w-px shrink-0 bg-border lg:block" />
@@ -558,10 +1043,10 @@ export default function SkillForm({
return (
<form id="skill-form" onSubmit={handleSubmit} className="space-y-4">
{!initSkillName && localDirectoryImport}
{metadataFields}
{fileTreeSection}
{instructionEditor()}
{advancedSettings}
{sideFooter}
</form>
);
+7 -2
View File
@@ -39,7 +39,7 @@ export default function SkillsPage() {
return <SkillDetailContent id={detailId} />;
}
function handleImportedSkills(skillNames: string[]) {
function handleImportedSkills(_skillNames: string[]) {
void refreshSkills();
setActiveView(null);
navigate('/home/add-extension');
@@ -54,7 +54,12 @@ export default function SkillsPage() {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}