mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-19 03:54:19 +00:00
feat(web): improve skill import flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user