From 4057821404cb4f2f0eb5da5c53a8330bb5a77e12 Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Wed, 25 Mar 2026 09:44:49 -0300 Subject: [PATCH] =?UTF-8?q?feat(skills):=20Skill=20Marketplace=20(Bibliote?= =?UTF-8?q?ca)=20=E2=80=94=20Fase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servidor: - GET /api/skills/marketplace: lista skills namespace=system/active, marca quais já foram importadas pelo tenant (campo imported) - POST /api/skills/marketplace/:id/import: clona skill system→tenant com extends appontando para a origem UI (Skills.tsx): - Toggle "Minhas Skills" / "Biblioteca" no header - Grid de cards 2 colunas com nome, slug, descrição, tags clicáveis, trigger type e versão - Botão "Importar" → feedback imediato (badge "Importada" + highlight verde) - Filtro por busca e tag na Biblioteca - Estado vazio com instrução para admins publicarem skills system Co-Authored-By: Claude Sonnet 4.6 --- client/src/pages/Skills.tsx | 191 +++++++++++++++++++++++++++++++++++- server/skills/routes.ts | 96 +++++++++++++++++- 2 files changed, 282 insertions(+), 5 deletions(-) diff --git a/client/src/pages/Skills.tsx b/client/src/pages/Skills.tsx index 83a8424..cdb2d6d 100644 --- a/client/src/pages/Skills.tsx +++ b/client/src/pages/Skills.tsx @@ -38,6 +38,11 @@ import { ChevronRight, History, Copy, + Store, + Download, + CheckCheck, + Tag, + Sparkles, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import Editor from "@monaco-editor/react"; @@ -52,6 +57,7 @@ interface Skill { namespace: string; status: string; triggerType: string | null; + version: string; body: string | null; parametersSchema: Record | null; tags: string[] | null; @@ -73,6 +79,10 @@ interface SkillExecution { completedAt: string | null; } +interface MarketplaceSkill extends Skill { + imported: boolean; +} + const EMPTY_SKILL = { name: "", slug: "", @@ -133,6 +143,11 @@ export default function Skills() { const [historyOpen, setHistoryOpen] = useState(false); const [historySkill, setHistorySkill] = useState(null); + const [view, setView] = useState<"minhas" | "marketplace">("minhas"); + const [mktSearch, setMktSearch] = useState(""); + const [mktTag, setMktTag] = useState("all"); + const [importedId, setImportedId] = useState(null); + // Ref para skills — usado no completion provider sem closure stale const skillsRef = useRef([]); const completionDisposable = useRef<{ dispose(): void } | null>(null); @@ -160,6 +175,18 @@ export default function Skills() { enabled: !!historySkill && historyOpen, }); + const { data: mktData, isLoading: mktLoading } = useQuery<{ skills: MarketplaceSkill[] }>({ + queryKey: ["/api/skills/marketplace", mktSearch, mktTag], + queryFn: async () => { + const params = new URLSearchParams(); + if (mktSearch) params.set("search", mktSearch); + if (mktTag !== "all") params.set("tag", mktTag); + const res = await fetch(`/api/skills/marketplace?${params}`); + return res.json(); + }, + enabled: view === "marketplace", + }); + // ── Mutations ────────────────────────────────────────────────────────────── const saveMutation = useMutation({ @@ -210,6 +237,21 @@ export default function Skills() { onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }), }); + const importMutation = useMutation({ + mutationFn: async (skillId: string) => { + const res = await fetch(`/api/skills/marketplace/${skillId}/import`, { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ skill: Skill }>; + }, + onSuccess: (data) => { + setImportedId(data.skill.id); + qc.invalidateQueries({ queryKey: ["/api/skills/marketplace"] }); + qc.invalidateQueries({ queryKey: ["/api/skills"] }); + toast({ title: `"${data.skill.name}" importada para suas skills` }); + }, + onError: (e: any) => toast({ title: "Erro ao importar", description: e.message, variant: "destructive" }), + }); + const executeMutation = useMutation({ mutationFn: async ({ skillId, params }: { skillId: string; params: string }) => { let inputParams: Record = {}; @@ -351,7 +393,7 @@ export default function Skills() { // ── Render ───────────────────────────────────────────────────────────────── return ( - +
{/* Header */} @@ -360,11 +402,32 @@ export default function Skills() {

Skills

Objetos reutilizáveis — herança, composição, polimorfismo

- +
+
+ + +
+ {view === "minhas" && ( + + )} +
+ {/* ── VIEW: MINHAS SKILLS ──────────────────────────────────────────────── */} + {view === "minhas" && (<> + {/* Filters */}
@@ -486,6 +549,126 @@ export default function Skills() {
)} + )} + + {/* ── VIEW: BIBLIOTECA (MARKETPLACE) ──────────────────────────────────── */} + {view === "marketplace" && (<> + + {/* Marketplace filters */} +
+
+ + setMktSearch(e.target.value)} + className="pl-8 h-8 text-sm bg-white/5 border-white/10" + /> +
+ +
+ + {/* Marketplace grid */} + + {mktLoading ? ( +
+ +
+ ) : !mktData?.skills?.length ? ( +
+ +

Biblioteca vazia

+

+ Ainda não há skills do sistema publicadas. Skills com namespace system e status active aparecerão aqui. +

+
+ ) : ( +
+ {mktData.skills.map(skill => ( + + + {/* Top row */} +
+
+
+ + {skill.name} +
+

/skill:system/{skill.slug}

+
+ + {/* Import button */} + {skill.imported || importedId === skill.id ? ( + + Importada + + ) : ( + + )} +
+ + {/* Description */} + {skill.description && ( +

{skill.description}

+ )} + + {/* Tags */} + {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map(tag => ( + setMktTag(tag)} + className="text-[10px] bg-white/10 hover:bg-white/20 px-2 py-0.5 rounded cursor-pointer transition-colors" + > + {tag} + + ))} +
+ )} + + {/* Footer */} +
+ + {skill.triggerType ?? "manual"} + + v{skill.version} +
+
+
+ ))} +
+ )} +
+ )}
{/* ── Edit / Create Dialog ────────────────────────────────────────────── */} diff --git a/server/skills/routes.ts b/server/skills/routes.ts index 4a9aacb..d52f8d9 100644 --- a/server/skills/routes.ts +++ b/server/skills/routes.ts @@ -5,7 +5,7 @@ import { skillExecutions, insertArcadiaSkillSchema, } from "@shared/schema"; -import { eq, and, desc, ilike, or } from "drizzle-orm"; +import { eq, and, desc, ilike, or, ne } from "drizzle-orm"; import { z } from "zod"; import { skillEngine } from "./engine"; @@ -197,6 +197,100 @@ export function registerSkillRoutes(app: Express): void { } }); + // ── Marketplace: listar skills do sistema disponíveis ──────────────────── + + app.get("/api/skills/marketplace", async (req: Request, res: Response) => { + try { + const search = req.query.search as string | undefined; + const tag = req.query.tag as string | undefined; + const tid = tenantId(req); + + const conditions: any[] = [ + eq(arcadiaSkills.namespace, "system"), + eq(arcadiaSkills.status, "active"), + ]; + + if (search) { + conditions.push( + or( + ilike(arcadiaSkills.name, `%${search}%`), + ilike(arcadiaSkills.description, `%${search}%`), + ilike(arcadiaSkills.slug, `%${search}%`) + ) + ); + } + + const skills = await db + .select() + .from(arcadiaSkills) + .where(and(...conditions)) + .orderBy(desc(arcadiaSkills.createdAt)) + .limit(100); + + // Marcar quais já foram importadas pelo tenant + let importedSlugs: string[] = []; + if (tid) { + const tenantSkills = await db + .select({ slug: arcadiaSkills.slug }) + .from(arcadiaSkills) + .where(and(eq(arcadiaSkills.tenantId, tid), ne(arcadiaSkills.namespace, "system"))); + importedSlugs = tenantSkills.map(s => s.slug); + } + + const result = skills + .filter(s => !tag || (s.tags ?? []).includes(tag)) + .map(s => ({ ...s, imported: importedSlugs.includes(s.slug) })); + + res.json({ skills: result }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Marketplace: importar skill do sistema para o tenant ────────────────── + + app.post("/api/skills/marketplace/:id/import", async (req: Request, res: Response) => { + try { + const tid = tenantId(req); + const uid = userId(req); + + const [source] = await db + .select() + .from(arcadiaSkills) + .where(and(eq(arcadiaSkills.id, req.params.id), eq(arcadiaSkills.namespace, "system"))) + .limit(1); + + if (!source) return res.status(404).json({ error: "Skill não encontrada no marketplace" }); + + // Clonar para o namespace tenant + const [imported] = await db + .insert(arcadiaSkills) + .values({ + name: source.name, + slug: source.slug, + description: source.description, + version: source.version, + icon: source.icon, + tags: source.tags, + namespace: "tenant", + tenantId: tid, + extends: [`/skill:system/${source.slug}`], + body: source.body, + parametersSchema: source.parametersSchema, + returnSchema: source.returnSchema, + triggerType: source.triggerType, + triggerConfig: source.triggerConfig, + status: "draft", + createdBy: uid, + } as any) + .returning(); + + res.status(201).json({ skill: imported, importedFrom: source.id }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + // ── Execução por ID ─────────────────────────────────────────────────────── app.get("/api/skills/executions/:executionId", async (req: Request, res: Response) => {