feat(skills): Skill Marketplace (Biblioteca) — Fase 2

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 <noreply@anthropic.com>
This commit is contained in:
Jonas Pacheco 2026-03-25 09:44:49 -03:00
parent 194a8dcc1b
commit 4057821404
2 changed files with 282 additions and 5 deletions

View File

@ -38,6 +38,11 @@ import {
ChevronRight, ChevronRight,
History, History,
Copy, Copy,
Store,
Download,
CheckCheck,
Tag,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
@ -52,6 +57,7 @@ interface Skill {
namespace: string; namespace: string;
status: string; status: string;
triggerType: string | null; triggerType: string | null;
version: string;
body: string | null; body: string | null;
parametersSchema: Record<string, unknown> | null; parametersSchema: Record<string, unknown> | null;
tags: string[] | null; tags: string[] | null;
@ -73,6 +79,10 @@ interface SkillExecution {
completedAt: string | null; completedAt: string | null;
} }
interface MarketplaceSkill extends Skill {
imported: boolean;
}
const EMPTY_SKILL = { const EMPTY_SKILL = {
name: "", name: "",
slug: "", slug: "",
@ -133,6 +143,11 @@ export default function Skills() {
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [historySkill, setHistorySkill] = useState<Skill | null>(null); const [historySkill, setHistorySkill] = useState<Skill | null>(null);
const [view, setView] = useState<"minhas" | "marketplace">("minhas");
const [mktSearch, setMktSearch] = useState("");
const [mktTag, setMktTag] = useState("all");
const [importedId, setImportedId] = useState<string | null>(null);
// Ref para skills — usado no completion provider sem closure stale // Ref para skills — usado no completion provider sem closure stale
const skillsRef = useRef<Skill[]>([]); const skillsRef = useRef<Skill[]>([]);
const completionDisposable = useRef<{ dispose(): void } | null>(null); const completionDisposable = useRef<{ dispose(): void } | null>(null);
@ -160,6 +175,18 @@ export default function Skills() {
enabled: !!historySkill && historyOpen, 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 ────────────────────────────────────────────────────────────── // ── Mutations ──────────────────────────────────────────────────────────────
const saveMutation = useMutation({ const saveMutation = useMutation({
@ -210,6 +237,21 @@ export default function Skills() {
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }), 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({ const executeMutation = useMutation({
mutationFn: async ({ skillId, params }: { skillId: string; params: string }) => { mutationFn: async ({ skillId, params }: { skillId: string; params: string }) => {
let inputParams: Record<string, unknown> = {}; let inputParams: Record<string, unknown> = {};
@ -351,7 +393,7 @@ export default function Skills() {
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<BrowserFrame title="Skills" icon="⚡"> <BrowserFrame>
<div className="flex flex-col h-full bg-[#0a0f1a] text-white"> <div className="flex flex-col h-full bg-[#0a0f1a] text-white">
{/* Header */} {/* Header */}
@ -360,10 +402,31 @@ export default function Skills() {
<h1 className="text-lg font-semibold">Skills</h1> <h1 className="text-lg font-semibold">Skills</h1>
<p className="text-xs text-muted-foreground">Objetos reutilizáveis herança, composição, polimorfismo</p> <p className="text-xs text-muted-foreground">Objetos reutilizáveis herança, composição, polimorfismo</p>
</div> </div>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-white/10 overflow-hidden">
<button
onClick={() => setView("minhas")}
className={`px-3 py-1.5 text-xs flex items-center gap-1.5 transition-colors ${view === "minhas" ? "bg-[#c89b3c] text-black font-medium" : "text-white/60 hover:text-white hover:bg-white/5"}`}
>
<Zap className="w-3.5 h-3.5" /> Minhas Skills
</button>
<button
onClick={() => setView("marketplace")}
className={`px-3 py-1.5 text-xs flex items-center gap-1.5 transition-colors ${view === "marketplace" ? "bg-[#c89b3c] text-black font-medium" : "text-white/60 hover:text-white hover:bg-white/5"}`}
>
<Store className="w-3.5 h-3.5" /> Biblioteca
</button>
</div>
{view === "minhas" && (
<Button size="sm" onClick={openNew} className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black"> <Button size="sm" onClick={openNew} className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black">
<Plus className="w-4 h-4 mr-1" /> Nova Skill <Plus className="w-4 h-4 mr-1" /> Nova Skill
</Button> </Button>
)}
</div> </div>
</div>
{/* ── VIEW: MINHAS SKILLS ──────────────────────────────────────────────── */}
{view === "minhas" && (<>
{/* Filters */} {/* Filters */}
<div className="flex gap-2 p-3 border-b border-white/10"> <div className="flex gap-2 p-3 border-b border-white/10">
@ -486,6 +549,126 @@ export default function Skills() {
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
</>)}
{/* ── VIEW: BIBLIOTECA (MARKETPLACE) ──────────────────────────────────── */}
{view === "marketplace" && (<>
{/* Marketplace filters */}
<div className="flex gap-2 p-3 border-b border-white/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Buscar na biblioteca..."
value={mktSearch}
onChange={e => setMktSearch(e.target.value)}
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
/>
</div>
<Select value={mktTag} onValueChange={setMktTag}>
<SelectTrigger className="w-36 h-8 text-xs bg-white/5 border-white/10">
<Tag className="w-3 h-3 mr-1" />
<SelectValue placeholder="Tag" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as tags</SelectItem>
<SelectItem value="financeiro">Financeiro</SelectItem>
<SelectItem value="crm">CRM</SelectItem>
<SelectItem value="relatorio">Relatório</SelectItem>
<SelectItem value="automacao">Automação</SelectItem>
<SelectItem value="ia">IA</SelectItem>
</SelectContent>
</Select>
</div>
{/* Marketplace grid */}
<ScrollArea className="flex-1">
{mktLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-[#c89b3c]" />
</div>
) : !mktData?.skills?.length ? (
<div className="flex flex-col items-center justify-center h-60 text-muted-foreground gap-3">
<Store className="w-12 h-12 opacity-20" />
<p className="text-sm font-medium">Biblioteca vazia</p>
<p className="text-xs text-center max-w-xs opacity-70">
Ainda não skills do sistema publicadas. Skills com namespace <code className="bg-white/10 px-1 rounded">system</code> e status <code className="bg-white/10 px-1 rounded">active</code> aparecerão aqui.
</p>
</div>
) : (
<div className="p-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{mktData.skills.map(skill => (
<Card
key={skill.id}
className={`border transition-all ${importedId === skill.id ? "border-green-500/40 bg-green-500/5" : "border-white/10 bg-white/5 hover:bg-white/8"}`}
>
<CardContent className="p-4">
{/* Top row */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<Sparkles className="w-3.5 h-3.5 text-[#c89b3c] flex-shrink-0" />
<span className="font-semibold text-sm truncate">{skill.name}</span>
</div>
<p className="text-[10px] font-mono text-white/40">/skill:system/{skill.slug}</p>
</div>
{/* Import button */}
{skill.imported || importedId === skill.id ? (
<Badge variant="outline" className="text-[10px] border-green-500/40 text-green-400 bg-green-500/10 flex-shrink-0 gap-1">
<CheckCheck className="w-3 h-3" /> Importada
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-xs border-[#c89b3c]/40 text-[#c89b3c] hover:bg-[#c89b3c]/10 flex-shrink-0 gap-1"
onClick={() => importMutation.mutate(skill.id)}
disabled={importMutation.isPending && importMutation.variables === skill.id}
>
{importMutation.isPending && importMutation.variables === skill.id
? <Loader2 className="w-3 h-3 animate-spin" />
: <Download className="w-3 h-3" />
}
Importar
</Button>
)}
</div>
{/* Description */}
{skill.description && (
<p className="text-xs text-white/60 mb-3 line-clamp-2">{skill.description}</p>
)}
{/* Tags */}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.tags.map(tag => (
<span
key={tag}
onClick={() => setMktTag(tag)}
className="text-[10px] bg-white/10 hover:bg-white/20 px-2 py-0.5 rounded cursor-pointer transition-colors"
>
{tag}
</span>
))}
</div>
)}
{/* Footer */}
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-white/10">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-white/20 text-white/40">
{skill.triggerType ?? "manual"}
</Badge>
<span className="text-[10px] text-white/30 ml-auto">v{skill.version}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</>)}
</div> </div>
{/* ── Edit / Create Dialog ────────────────────────────────────────────── */} {/* ── Edit / Create Dialog ────────────────────────────────────────────── */}

View File

@ -5,7 +5,7 @@ import {
skillExecutions, skillExecutions,
insertArcadiaSkillSchema, insertArcadiaSkillSchema,
} from "@shared/schema"; } 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 { z } from "zod";
import { skillEngine } from "./engine"; 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 ─────────────────────────────────────────────────────── // ── Execução por ID ───────────────────────────────────────────────────────
app.get("/api/skills/executions/:executionId", async (req: Request, res: Response) => { app.get("/api/skills/executions/:executionId", async (req: Request, res: Response) => {