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:
parent
194a8dcc1b
commit
4057821404
|
|
@ -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 há 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 ────────────────────────────────────────────── */}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue