Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,8 @@
"invalidSkillUrlSource": "Enter a valid HTTP(S) skill URL; supported sources are validated by the server adapter",
"zipHintTitle": "ZIP layout (each top-level folder = one skill):",
"zipHintDetail": "Each folder must contain SKILL.md and may include scripts or other files. Only .zip is supported.",
"zipDragDropHint": "Drag and drop your files here to upload",
"zipSelected": "Selected: {{name}}",
"chooseZip": "Choose ZIP file",
"noZipSelected": "No file selected",
"removeZip": "Remove",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,8 @@
"invalidSkillUrlSource": "请输入有效的 HTTP(S) 技能 URL;具体来源由服务端适配器校验",
"zipHintTitle": "压缩包结构(每个一级文件夹 = 一个技能):",
"zipHintDetail": "每个文件夹内必须包含 SKILL.md,可附带脚本等其他文件。仅支持 .zip。",
"zipDragDropHint": "将需要上传的文件拖拽到此处",
"zipSelected": "已选择: {{name}}",
"chooseZip": "选择 ZIP 文件",
"noZipSelected": "未选择文件",
"removeZip": "移除",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ describe("<SkillImportModal /> local zip import", () => {
'input[type="file"]',
) as HTMLInputElement;
setInputFiles(fileInput, [zipFile]);
await user.click(screen.getByText("skills.removeZip"));
const removeButtons = screen.getAllByText("skills.removeZip");
// Click either remove button
await user.click(removeButtons[0]);
expect(
screen.getByRole("button", { name: "skills.importSkills" }),
).toBeDisabled();
Expand Down
84 changes: 73 additions & 11 deletions dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function SkillImportModal({
const [zipError, setZipError] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [parsing, setParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
Expand All @@ -70,9 +71,24 @@ export function SkillImportModal({
setZipError("");
setOverwrite(false);
setParsing(false);
setIsDragging(false);
}
}, [open]);

const handleFileSelect = (file: File | null) => {
if (!file) {
setZipFile(null);
return;
}
if (!file.name.toLowerCase().endsWith(".zip")) {
setZipFile(null);
setZipError(t("skills.zipOnly"));
return;
}
setZipError("");
setZipFile(file);
};

const handleUrlChange = (value: string) => {
setImportUrl(value);
const trimmed = value.trim();
Expand Down Expand Up @@ -133,6 +149,28 @@ export function SkillImportModal({
? !importUrl.trim() || !!importUrlError
: !zipFile || !!zipError);

const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (busy) return;
setIsDragging(true);
};

const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};

const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (busy) return;

const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFileSelect(files[0]);
}
};

return (
<Modal
title={t("skills.importSkills")}
Expand Down Expand Up @@ -222,6 +260,38 @@ export function SkillImportModal({
</p>
</div>

<div
className={`${styles.zipDragDrop} ${isDragging ? styles.zipDragDropActive : ""} ${zipFile ? styles.zipDragDropHasFile : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !busy && fileInputRef.current?.click()}
>
<div className={styles.zipDragDropIcon}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none">
<path d="M20 17V19C20 20.1046 19.1046 21 18 21H6C4.89543 21 4 20.1046 4 19V17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 7L12 2L17 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className={styles.zipDragDropText}>
{zipFile ? t("skills.zipSelected", { name: zipFile.name }) : t("skills.zipDragDropHint")}
</div>
{zipFile ? (
<Button
type="link"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
setZipFile(null);
setZipError("");
}}
>
{t("skills.removeZip")}
</Button>
) : null}
</div>

<input
ref={fileInputRef}
type="file"
Expand All @@ -231,19 +301,10 @@ export function SkillImportModal({
onChange={(event) => {
const next = event.target.files?.[0] ?? null;
event.target.value = "";
setZipError("");
if (!next) {
setZipFile(null);
return;
}
if (!next.name.toLowerCase().endsWith(".zip")) {
setZipFile(null);
setZipError(t("skills.zipOnly"));
return;
}
setZipFile(next);
handleFileSelect(next);
}}
/>

<div className={styles.zipPickerRow}>
<Button
icon={<UploadIcon size={14} />}
Expand All @@ -268,6 +329,7 @@ export function SkillImportModal({
</Button>
) : null}
</div>

{zipError ? (
<div className={styles.importUrlError}>{zipError}</div>
) : null}
Expand Down
45 changes: 45 additions & 0 deletions dashboard/src/pages/Agent/Skills/index.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -965,10 +965,55 @@
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 16px;
}

.zipFileName {
color: var(--fn-text-secondary);
font-size: 13px;
word-break: break-all;
}

.zipDragDrop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 24px;
border: 2px dashed var(--fn-border-input);
border-radius: var(--fn-radius-md);
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--fn-bg-tertiary);

&:hover {
border-color: var(--fn-color-brand);
background-color: color-mix(in srgb, var(--fn-color-brand) 5%, var(--fn-bg-tertiary));
}
}

.zipDragDropActive {
border-color: var(--fn-color-brand);
background-color: color-mix(in srgb, var(--fn-color-brand) 10%, var(--fn-bg-tertiary));
transform: scale(1.01);
}

.zipDragDropHasFile {
border-style: solid;
border-color: var(--fn-color-brand);
}

.zipDragDropIcon {
color: var(--fn-text-tertiary);
opacity: 0.8;
display: flex;
align-items: center;
justify-content: center;
}

.zipDragDropText {
color: var(--fn-text-secondary);
font-size: 14px;
text-align: center;
}
Loading