From 9e62227104ab49605c78692feadab3f6cd0b242f Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 18 May 2026 18:33:39 +0800 Subject: [PATCH] feat(web): improve skill import flow --- web/src/app/home/add-extension/page.tsx | 58 +- .../components/skill-form/SkillForm.tsx | 639 +++++++++++++++--- web/src/app/home/skills/page.tsx | 9 +- web/src/i18n/locales/en-US.ts | 13 +- web/src/i18n/locales/es-ES.ts | 131 +++- web/src/i18n/locales/ja-JP.ts | 114 ++++ web/src/i18n/locales/ru-RU.ts | 129 ++++ web/src/i18n/locales/th-TH.ts | 125 ++++ web/src/i18n/locales/vi-VN.ts | 129 ++++ web/src/i18n/locales/zh-Hans.ts | 12 +- web/src/i18n/locales/zh-Hant.ts | 44 +- 11 files changed, 1270 insertions(+), 133 deletions(-) diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index 3b0520b3..644e7b82 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -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(null); const [mcpTesting, setMcpTesting] = useState(false); const [mcpDraft, setMcpDraft] = useState(); - const [skillDraft, setSkillDraft] = useState(); // 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 { 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() { -

- {t('skills.createSkill')} -

- - -
- {}} - onDraftChange={setSkillDraft} - /> -
- -
- -
- - )} - {/* ===== GitHub Install View ===== */} {popoverView === 'github' && (
diff --git a/web/src/app/home/skills/components/skill-form/SkillForm.tsx b/web/src/app/home/skills/components/skill-form/SkillForm.tsx index 510b36b1..b1128666 100644 --- a/web/src/app/home/skills/components/skill-form/SkillForm.tsx +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -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 { + 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(function FileTree( )} ) : ( - + )} {entry.name} {!entry.is_dir && entry.size !== null && ( @@ -226,6 +430,125 @@ const FileTree = forwardRef(function FileTree( ); }); +interface LocalFileTreeProps { + entries: DirectoryTreeNode[]; + fileMap: Map; + 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>(new Set()); + const [selectedPath, setSelectedPath] = useState(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 ( +
+
+ entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path) + } + > + {entry.is_dir ? ( + <> + {isExpanded ? ( + + ) : ( + + )} + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + + )} + {entry.name} + {!entry.is_dir && entry.size !== null && ( + + {entry.size > 1024 + ? `${Math.round(entry.size / 1024)}KB` + : `${entry.size}B`} + + )} +
+ {entry.is_dir && isExpanded && ( +
+ {entry.children.map((child) => renderEntry(child, depth + 1))} +
+ )} +
+ ); + }; + + return ( +
+
+ {entries.length === 0 && ( +
+ {t('skills.noFiles')} +
+ )} + {entries.map((entry) => renderEntry(entry))} +
+
+ ); +} + const emptySkillDraft: SkillFormDraft = { skill: { name: '', @@ -251,13 +574,24 @@ export default function SkillForm({ const [skill, setSkill] = useState>( initialDraftRef.current.skill, ); - const [scanning, setScanning] = useState(false); + const [importingDirectory, setImportingDirectory] = useState(false); + const [installingDirectory, setInstallingDirectory] = useState(false); + const [directoryZipFile, setDirectoryZipFile] = useState(null); + const [directoryPreview, setDirectoryPreview] = useState( + null, + ); + const [directorySourceName, setDirectorySourceName] = useState(''); + const [directoryTree, setDirectoryTree] = useState([]); + const [directoryFileMap, setDirectoryFileMap] = useState>( + new Map(), + ); const [showAdvanced, setShowAdvanced] = useState( initialDraftRef.current.showAdvanced, ); const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(''); const fileTreeRef = useRef(null); + const directoryInputRef = useRef(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, + ) { + 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)} />
@@ -401,7 +851,7 @@ export default function SkillForm({ } placeholder={t('skills.skillSlugPlaceholder')} className="font-mono" - disabled={Boolean(initSkillName)} + disabled={Boolean(initSkillName || directoryPreview)} />

{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)} /> @@ -423,7 +874,7 @@ export default function SkillForm({ const fileTreeSection = ( <> - {initSkillName && ( + {initSkillName ? (

+ ) : ( + directoryPreview && ( +
+ +
+ ) )} ); @@ -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({ ); - const advancedSettings = ( -
- -
- setSkill({ ...skill, package_root: e.target.value })} - placeholder={`data/skills/${skill.name || ''}/`} - className="flex-1" - disabled={Boolean(initSkillName)} - /> + const localDirectoryImport = ( +
+ + + {directoryPreview && ( -
-

- {t('skills.packageRootHelp')} -

+ )}
); @@ -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" >
+ {!initSkillName && ( + + + {t('skills.importLocalDirectory')} + + {localDirectoryImport} + + )} {t('bots.basicInfo')} {metadataFields} - {initSkillName && ( + {(initSkillName || directoryPreview) && ( {t('skills.files')} - + {initSkillName && ( + + )} - + {initSkillName ? ( + + ) : ( + + )} )} - - - {t('skills.advancedSettings')} - - {advancedSettings} - {sideFooter}
@@ -558,10 +1043,10 @@ export default function SkillForm({ return (
+ {!initSkillName && localDirectoryImport} {metadataFields} {fileTreeSection} {instructionEditor()} - {advancedSettings} {sideFooter}
); diff --git a/web/src/app/home/skills/page.tsx b/web/src/app/home/skills/page.tsx index d150efa6..7fb305f1 100644 --- a/web/src/app/home/skills/page.tsx +++ b/web/src/app/home/skills/page.tsx @@ -39,7 +39,7 @@ export default function SkillsPage() { return ; } - 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 (
-

{t('skills.createSkill')}

+
+

{t('skills.createSkill')}

+

+ {t('skills.createSkillDescription')} +

+