From d89d7cce786f2fde80545db7ec604e3baf046ea9 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 8 May 2026 12:26:47 -0400 Subject: [PATCH] create zips --- ui/goose2/src-tauri/Cargo.lock | 40 ++++ ui/goose2/src-tauri/Cargo.toml | 1 + ui/goose2/src-tauri/src/commands/agents.rs | 91 --------- ui/goose2/src-tauri/src/commands/archive.rs | 190 ++++++++++++++++++ ui/goose2/src-tauri/src/commands/mod.rs | 2 +- ui/goose2/src-tauri/src/lib.rs | 2 +- .../src/features/agents/lib/personaImport.ts | 44 ++-- .../src/features/agents/ui/AgentsView.tsx | 65 ++---- .../src/features/agents/ui/PersonaGallery.tsx | 5 +- ui/goose2/src/features/skills/api/archive.ts | 12 ++ ui/goose2/src/features/skills/api/skills.ts | 20 +- .../skills/hooks/useSkillImportExport.ts | 45 ++++- .../features/skills/lib/formatSkillError.ts | 14 ++ .../src/features/skills/ui/SkillsView.tsx | 22 +- ui/goose2/src/shared/api/agents.ts | 17 -- .../src/shared/i18n/locales/en/agents.json | 2 - .../src/shared/i18n/locales/es/agents.json | 2 - 17 files changed, 345 insertions(+), 229 deletions(-) delete mode 100644 ui/goose2/src-tauri/src/commands/agents.rs create mode 100644 ui/goose2/src-tauri/src/commands/archive.rs create mode 100644 ui/goose2/src/features/skills/api/archive.ts create mode 100644 ui/goose2/src/features/skills/lib/formatSkillError.ts diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index 65cf9897fd..e22512aeb3 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -1219,6 +1219,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1673,6 +1674,7 @@ dependencies = [ "tempfile", "tokio", "uuid", + "zip", ] [[package]] @@ -5151,6 +5153,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6468,12 +6476,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.13.1", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index c0816e82c2..c50e03c538 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ ignore = "0.4.25" base64 = "0.22" mime_guess = "2" tauri-plugin-shell = "2" +zip = { version = "8.6", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/ui/goose2/src-tauri/src/commands/agents.rs b/ui/goose2/src-tauri/src/commands/agents.rs deleted file mode 100644 index fe52cb5940..0000000000 --- a/ui/goose2/src-tauri/src/commands/agents.rs +++ /dev/null @@ -1,91 +0,0 @@ -use serde::Serialize; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportFileReadResult { - pub file_bytes: Vec, - pub file_name: String, -} - -fn validate_import_persona_path(source_path: &str) -> Result { - let path = PathBuf::from(source_path); - - if path.as_os_str().is_empty() { - return Err("Selected file path is empty".to_string()); - } - - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .ok_or_else(|| "Unsupported file type. Expected a .json file.".to_string())?; - if !extension.eq_ignore_ascii_case("json") { - return Err("Unsupported file type. Expected a .json file.".to_string()); - } - - let metadata = std::fs::metadata(&path) - .map_err(|err| format!("Failed to access import file '{}': {}", path.display(), err))?; - if !metadata.is_file() { - return Err(format!( - "Selected import path '{}' is not a file", - path.display() - )); - } - - Ok(path) -} - -#[tauri::command] -pub fn read_import_persona_file(source_path: String) -> Result { - let path = validate_import_persona_path(&source_path)?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| "Selected file is missing a valid filename".to_string())? - .to_string(); - let file_bytes = std::fs::read(&path) - .map_err(|err| format!("Failed to read import file '{}': {}", path.display(), err))?; - - Ok(ImportFileReadResult { - file_bytes, - file_name, - }) -} - -#[cfg(test)] -mod tests { - use super::validate_import_persona_path; - - #[test] - fn validate_import_persona_path_rejects_non_json_files() { - let path = std::env::temp_dir().join("persona-import.txt"); - std::fs::write(&path, b"{}").unwrap(); - - let result = validate_import_persona_path(path.to_str().unwrap()); - - assert!(result.is_err()); - let _ = std::fs::remove_file(path); - } - - #[test] - fn validate_import_persona_path_rejects_directories() { - let dir = std::env::temp_dir().join(format!("persona-import-dir-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - - let result = validate_import_persona_path(dir.to_str().unwrap()); - - assert!(result.is_err()); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn validate_import_persona_path_accepts_json_files() { - let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id())); - std::fs::write(&path, b"{}").unwrap(); - - let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap(); - - assert_eq!(validated, path); - let _ = std::fs::remove_file(validated); - } -} diff --git a/ui/goose2/src-tauri/src/commands/archive.rs b/ui/goose2/src-tauri/src/commands/archive.rs new file mode 100644 index 0000000000..38fe92305d --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/archive.rs @@ -0,0 +1,190 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::Serialize; +use std::fs; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use zip::write::SimpleFileOptions; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ZipArchiveResult { + pub data: String, + pub filename: String, +} + +fn validate_archive_root(source_path: &str) -> Result { + let path = PathBuf::from(source_path); + if path.as_os_str().is_empty() { + return Err("Selected directory path is empty".to_string()); + } + + let metadata = fs::metadata(&path) + .map_err(|err| format!("Failed to access directory '{}': {}", path.display(), err))?; + if !metadata.is_dir() { + return Err(format!( + "Selected path '{}' is not a directory", + path.display() + )); + } + + if !path.join("SKILL.md").is_file() { + return Err(format!( + "Selected directory '{}' must contain a SKILL.md file", + path.display() + )); + } + + Ok(path) +} + +fn archive_root_name(root: &Path) -> String { + root.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("skill") + .to_string() +} + +fn archive_filename(root: &Path) -> String { + format!("{}.skill.zip", archive_root_name(root)) +} + +fn archive_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn relative_zip_path(root: &Path, path: &Path) -> Result { + let relative = path.strip_prefix(root).map_err(|err| { + format!( + "Failed to resolve '{}' relative to '{}': {}", + path.display(), + root.display(), + err + ) + })?; + Ok(format!( + "{}/{}", + archive_root_name(root), + archive_path(relative) + )) +} + +fn should_skip_archive_dir(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git") | Some(".hg") | Some(".svn") + ) +} + +fn collect_files(root: &Path) -> Result, String> { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir) + .map_err(|err| format!("Failed to read directory '{}': {}", dir.display(), err))?; + for entry in entries { + let entry = entry.map_err(|err| { + format!( + "Failed to read directory entry in '{}': {}", + dir.display(), + err + ) + })?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|err| format!("Failed to inspect '{}': {}", path.display(), err))?; + if file_type.is_dir() { + if !should_skip_archive_dir(&path) { + stack.push(path); + } + } else if file_type.is_file() { + files.push(path); + } + } + } + + files.sort_by_key(|path| path.to_string_lossy().to_lowercase()); + Ok(files) +} + +fn zip_directory(root: &Path) -> Result, String> { + let mut cursor = Cursor::new(Vec::new()); + let mut zip = zip::ZipWriter::new(&mut cursor); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o644); + + for path in collect_files(root)? { + let name = relative_zip_path(root, &path)?; + zip.start_file(name, options) + .map_err(|err| format!("Failed to add file to archive: {err}"))?; + let bytes = fs::read(&path) + .map_err(|err| format!("Failed to read '{}': {}", path.display(), err))?; + zip.write_all(&bytes) + .map_err(|err| format!("Failed to write archive entry: {err}"))?; + } + + zip.finish() + .map_err(|err| format!("Failed to finish archive: {err}"))?; + Ok(cursor.into_inner()) +} + +#[tauri::command] +pub async fn create_zip_archive(source_path: String) -> Result { + tokio::task::spawn_blocking(move || { + let root = validate_archive_root(&source_path)?; + let bytes = zip_directory(&root)?; + Ok(ZipArchiveResult { + data: BASE64.encode(bytes), + filename: archive_filename(&root), + }) + }) + .await + .map_err(|err| format!("Failed to create archive: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::{archive_filename, validate_archive_root, zip_directory}; + use std::fs; + use tempfile::tempdir; + use zip::ZipArchive; + + #[test] + fn validates_skill_directory() { + let dir = tempdir().expect("tempdir"); + fs::write(dir.path().join("SKILL.md"), "---\nname: test\n---\n").expect("skill"); + + assert_eq!( + validate_archive_root(dir.path().to_str().unwrap()).unwrap(), + dir.path() + ); + } + + #[test] + fn rejects_directory_without_skill_file() { + let dir = tempdir().expect("tempdir"); + + let error = validate_archive_root(dir.path().to_str().unwrap()).expect_err("missing skill"); + + assert!(error.contains("SKILL.md")); + } + + #[test] + fn creates_archive_with_root_directory() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("my-skill"); + fs::create_dir_all(root.join("assets")).expect("dirs"); + fs::write(root.join("SKILL.md"), "---\nname: my-skill\n---\nBody").expect("skill"); + fs::write(root.join("assets").join("note.txt"), "note").expect("note"); + + let bytes = zip_directory(&root).expect("zip"); + let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).expect("archive"); + + assert!(archive.by_name("my-skill/SKILL.md").is_ok()); + assert!(archive.by_name("my-skill/assets/note.txt").is_ok()); + assert_eq!(archive_filename(&root), "my-skill.skill.zip"); + } +} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 29643efa25..0901233a86 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -1,6 +1,6 @@ pub mod acp; pub mod agent_setup; -pub mod agents; +pub mod archive; pub mod distro; pub mod doctor; pub mod git; diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 6664f1517d..4d1bf2d7ac 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -35,7 +35,7 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - commands::agents::read_import_persona_file, + commands::archive::create_zip_archive, commands::acp::get_goose_serve_url, commands::acp::get_goose_serve_host_info, commands::project_icons::scan_project_icons, diff --git a/ui/goose2/src/features/agents/lib/personaImport.ts b/ui/goose2/src/features/agents/lib/personaImport.ts index 0169d0ad39..b367fa24ee 100644 --- a/ui/goose2/src/features/agents/lib/personaImport.ts +++ b/ui/goose2/src/features/agents/lib/personaImport.ts @@ -1,33 +1,8 @@ -const MARKDOWN_MIME_TYPES = new Set(["", "text/markdown", "text/plain"]); - export interface ImportMessageDescriptor { - key: - | "view.importInvalidExtension" - | "view.importInvalidMimeType" - | "view.imported_one" - | "view.imported_other"; + key: "view.imported_one" | "view.imported_other"; options?: Record; } -export function validatePersonaImportFile( - file: Pick, -): ImportMessageDescriptor | null { - const lowerName = file.name.toLowerCase(); - if (!lowerName.endsWith(".agent.md") && !lowerName.endsWith(".persona.md")) { - return { - key: "view.importInvalidExtension", - } satisfies ImportMessageDescriptor; - } - - if (!MARKDOWN_MIME_TYPES.has(file.type)) { - return { - key: "view.importInvalidMimeType", - } satisfies ImportMessageDescriptor; - } - - return null; -} - export function formatImportSuccessMessage( importedCount: number, ): ImportMessageDescriptor { @@ -42,11 +17,16 @@ export function formatImportSuccessMessage( } export function formatAgentError(error: unknown, fallback: string): string { - if (typeof error === "string" && error.trim().length > 0) { - return error; + const message = + typeof error === "string" && error.trim().length > 0 + ? error.trim() + : error instanceof Error && error.message.trim().length > 0 + ? error.message.trim() + : fallback; + + if (typeof error !== "object" || error === null || !("data" in error)) { + return message; } - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - return fallback; + + return `${message}\n${JSON.stringify(error.data)}`; } diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index fc914be857..dd850b87bb 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -1,6 +1,5 @@ import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { open } from "@tauri-apps/plugin-dialog"; import { Plus, Upload } from "lucide-react"; import { toast } from "sonner"; import { downloadBase64File } from "@/shared/lib/encoding"; @@ -23,11 +22,7 @@ import { } from "@/features/agents/stores/agentSelectors"; import { PersonaGallery } from "@/features/agents/ui/PersonaGallery"; import { PersonaEditor } from "@/features/agents/ui/PersonaEditor"; -import { - exportPersona, - importPersonas, - readImportPersonaFile, -} from "@/shared/api/agents"; +import { exportPersona, importPersonas } from "@/shared/api/agents"; import { usePersonas } from "@/features/agents/hooks/usePersonas"; import type { Persona, @@ -37,9 +32,9 @@ import type { import { formatAgentError, formatImportSuccessMessage, - validatePersonaImportFile, } from "@/features/agents/lib/personaImport"; import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; export function AgentsView() { const { t } = useTranslation(["agents", "common"]); @@ -162,14 +157,6 @@ export function AgentsView() { toast.error(message); }, []); - const validateImportFile = useCallback( - (file: Pick) => { - const message = validatePersonaImportFile(file); - return message ? t(message.key, message.options) : null; - }, - [t], - ); - const handleImportFileBytes = useCallback( async (fileBytes: number[], fileName: string) => { try { @@ -184,40 +171,10 @@ export function AgentsView() { [refreshFromDisk, t], ); - const handleImportPicker = useCallback(async () => { - try { - const selected = await open({ - multiple: false, - directory: false, - title: t("common:actions.import"), - filters: [ - { - name: "Agent Markdown", - extensions: ["md"], - }, - ], - }); - - if (!selected || Array.isArray(selected)) { - return; - } - - const { fileBytes, fileName } = await readImportPersonaFile(selected); - const validationMessage = validateImportFile({ - name: fileName, - type: "", - }); - - if (validationMessage) { - toast.error(validationMessage); - return; - } - - await handleImportFileBytes(fileBytes, fileName); - } catch (err) { - toast.error(formatAgentError(err, t("view.importFailed"))); - } - }, [handleImportFileBytes, t, validateImportFile]); + const { fileInputRef, handleFileChange, openFilePicker } = useFileImportZone({ + onImportFile: handleImportFileBytes, + onImportError: handleImportError, + }); return (
@@ -238,7 +195,7 @@ export function AgentsView() { type="button" variant="outline-flat" size="sm" - onClick={() => void handleImportPicker()} + onClick={openFilePicker} > {t("common:actions.import")} @@ -252,6 +209,13 @@ export function AgentsView() { {t("view.newPersona")} +
@@ -273,7 +237,6 @@ export function AgentsView() { onExportPersona={handleExportPersona} onCreatePersona={() => openPersonaEditor()} onImportFile={handleImportFileBytes} - validateImportFile={validateImportFile} onImportError={handleImportError} isLoading={personasLoading} /> diff --git a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx index 6719980f1d..aa548edeab 100644 --- a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx @@ -18,7 +18,6 @@ interface PersonaGalleryProps { onExportPersona?: (persona: Persona) => void; onCreatePersona: () => void; onImportFile?: (fileBytes: number[], fileName: string) => void; - validateImportFile?: (file: Pick) => string | null; onImportError?: (message: string) => void; isLoading?: boolean; } @@ -47,7 +46,6 @@ export function PersonaGallery({ onExportPersona, onCreatePersona, onImportFile, - validateImportFile, onImportError, isLoading = false, }: PersonaGalleryProps) { @@ -55,7 +53,6 @@ export function PersonaGallery({ const { fileInputRef, isDragOver, dropHandlers, handleFileChange } = useFileImportZone({ onImportFile: onImportFile ?? (() => {}), - validateFile: validateImportFile, onImportError, }); const sorted = useMemo(() => { @@ -126,7 +123,7 @@ export function PersonaGallery({ diff --git a/ui/goose2/src/features/skills/api/archive.ts b/ui/goose2/src/features/skills/api/archive.ts new file mode 100644 index 0000000000..67b1cc301b --- /dev/null +++ b/ui/goose2/src/features/skills/api/archive.ts @@ -0,0 +1,12 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface ZipArchiveResult { + data: string; + filename: string; +} + +export async function createZipArchive( + sourcePath: string, +): Promise { + return invoke("create_zip_archive", { sourcePath }); +} diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 211df966d3..4e7ca74afb 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -242,8 +242,8 @@ export async function exportSkill( }; } -export async function importSkills( - fileBytes: number[], +async function importSkillsBase64( + data: string, fileName: string, ): Promise { const lowerName = fileName.toLowerCase(); @@ -253,7 +253,7 @@ export async function importSkills( const client = await getClient(); const response = await client.goose.GooseSourcesImport({ - data: bytesToBase64(fileBytes), + data, filename: fileName, type: SKILL_SOURCE_TYPE, global: true, @@ -261,3 +261,17 @@ export async function importSkills( return response.sources.filter(isFilesystemSkillSource).map(toSkillInfo); } + +export async function importSkills( + fileBytes: number[], + fileName: string, +): Promise { + return importSkillsBase64(bytesToBase64(fileBytes), fileName); +} + +export async function importSkillsArchive( + data: string, + fileName: string, +): Promise { + return importSkillsBase64(data, fileName); +} diff --git a/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts b/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts index dce92c33c1..df7f1b318d 100644 --- a/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts +++ b/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts @@ -1,11 +1,19 @@ import { useTranslation } from "react-i18next"; +import { open } from "@tauri-apps/plugin-dialog"; import { toast } from "sonner"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; -import { exportSkill, importSkills, type SkillInfo } from "../api/skills"; +import { createZipArchive } from "../api/archive"; +import { + exportSkill, + importSkills, + importSkillsArchive, + type SkillInfo, +} from "../api/skills"; +import { formatSkillError } from "../lib/formatSkillError"; import { downloadExport } from "../lib/skillsHelpers"; export function useSkillImportExport(onAfterImport: () => Promise) { - const { t } = useTranslation(["skills"]); + const { t } = useTranslation(["skills", "common"]); const handleExport = async (skill: SkillInfo) => { if (skill.sourceKind === "builtin") { @@ -22,16 +30,39 @@ export function useSkillImportExport(onAfterImport: () => Promise) { }; const handleImport = async (fileBytes: number[], fileName: string) => { + await importSkills(fileBytes, fileName); + await onAfterImport(); + toast.success(t("view.importSuccess")); + }; + + const handleImportDirectory = async () => { try { - await importSkills(fileBytes, fileName); + const selected = await open({ + multiple: false, + directory: true, + title: t("common:actions.import"), + }); + + if (!selected || Array.isArray(selected)) { + return; + } + + const archive = await createZipArchive(selected); + await importSkillsArchive(archive.data, archive.filename); await onAfterImport(); toast.success(t("view.importSuccess")); - } catch { - toast.error(t("view.importError")); + } catch (error) { + toast.error(formatSkillError(error, t("view.importError"))); } }; - const fileImport = useFileImportZone({ onImportFile: handleImport }); + const fileImport = useFileImportZone({ + onImportFile: (fileBytes, fileName) => { + void handleImport(fileBytes, fileName).catch((error) => { + toast.error(formatSkillError(error, t("view.importError"))); + }); + }, + }); - return { ...fileImport, handleExport }; + return { ...fileImport, handleImportDirectory, handleExport }; } diff --git a/ui/goose2/src/features/skills/lib/formatSkillError.ts b/ui/goose2/src/features/skills/lib/formatSkillError.ts new file mode 100644 index 0000000000..8676469a92 --- /dev/null +++ b/ui/goose2/src/features/skills/lib/formatSkillError.ts @@ -0,0 +1,14 @@ +export function formatSkillError(error: unknown, fallback: string): string { + const message = + typeof error === "string" && error.trim().length > 0 + ? error.trim() + : error instanceof Error && error.message.trim().length > 0 + ? error.message.trim() + : fallback; + + if (typeof error !== "object" || error === null || !("data" in error)) { + return message; + } + + return `${message}\n${JSON.stringify(error.data)}`; +} diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 56dada3aa2..fca18db2e2 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -209,14 +209,8 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { await loadSkills(); }, [loadSkills]); - const { - fileInputRef, - isDragOver, - dropHandlers, - handleFileChange, - openFilePicker, - handleExport, - } = useSkillImportExport(refreshSkills); + const { isDragOver, dropHandlers, handleImportDirectory, handleExport } = + useSkillImportExport(refreshSkills); const handleShare = useCallback( (skill: SkillInfo) => { @@ -273,7 +267,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { type="button" variant="outline-flat" size="xs" - onClick={openFilePicker} + onClick={() => void handleImportDirectory()} > {t("common:actions.import")} @@ -318,18 +312,10 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { isDragOver={isDragOver} dropHandlers={dropHandlers} onNewSkill={handleNewSkill} - onImport={openFilePicker} + onImport={() => void handleImportDirectory()} /> ) : null} - - {dialogs} ); diff --git a/ui/goose2/src/shared/api/agents.ts b/ui/goose2/src/shared/api/agents.ts index 121f3ae2ed..d59478519a 100644 --- a/ui/goose2/src/shared/api/agents.ts +++ b/ui/goose2/src/shared/api/agents.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import type { SourceEntry } from "@aaif/goose-sdk"; import { getClient } from "@/shared/api/acpConnection"; import { bytesToBase64 } from "@/shared/lib/encoding"; @@ -176,11 +175,6 @@ export async function importPersonas( fileBytes: number[], fileName: string, ): Promise { - const lowerName = fileName.toLowerCase(); - if (!lowerName.endsWith(".agent.md") && !lowerName.endsWith(".persona.md")) { - throw new Error("File must have a .agent.md or .persona.md extension"); - } - const client = await getClient(); const response = await client.goose.GooseSourcesImport({ data: bytesToBase64(fileBytes), @@ -191,14 +185,3 @@ export async function importPersonas( return response.sources.filter(isAgentSource).map(toPersona); } - -export interface ImportFileReadResult { - fileBytes: number[]; - fileName: string; -} - -export async function readImportPersonaFile( - sourcePath: string, -): Promise { - return invoke("read_import_persona_file", { sourcePath }); -} diff --git a/ui/goose2/src/shared/i18n/locales/en/agents.json b/ui/goose2/src/shared/i18n/locales/en/agents.json index f93784d156..89184db4d6 100644 --- a/ui/goose2/src/shared/i18n/locales/en/agents.json +++ b/ui/goose2/src/shared/i18n/locales/en/agents.json @@ -76,8 +76,6 @@ "emptyAgentsTitle": "No agents yet", "exportFailed": "Failed to export agent.", "exportedTo": "Exported to {{filename}}", - "importInvalidExtension": "Unsupported file type. Choose a .agent.md or .persona.md file.", - "importInvalidMimeType": "Unsupported file type. Choose a Markdown file.", "importFailed": "Failed to import agent.", "imported_one": "Imported {{count}} agent.", "imported_other": "Imported {{count}} agents.", diff --git a/ui/goose2/src/shared/i18n/locales/es/agents.json b/ui/goose2/src/shared/i18n/locales/es/agents.json index 8cf4199d16..ee8e1b36bf 100644 --- a/ui/goose2/src/shared/i18n/locales/es/agents.json +++ b/ui/goose2/src/shared/i18n/locales/es/agents.json @@ -76,8 +76,6 @@ "emptyAgentsTitle": "Aún no hay agentes", "exportFailed": "No se pudo exportar el agente.", "exportedTo": "Exportado a {{filename}}", - "importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .agent.md o .persona.md.", - "importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo Markdown.", "importFailed": "No se pudo importar el agente.", "imported_one": "Se importó {{count}} agente.", "imported_other": "Se importaron {{count}} agentes.",