arcadiasuite/client/src/pages/Skills.tsx

978 lines
42 KiB
TypeScript

import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
Play,
Trash2,
Pencil,
Loader2,
CheckCircle,
XCircle,
Clock,
Search,
Zap,
Code2,
ListChecks,
ChevronRight,
History,
Copy,
Store,
Download,
CheckCheck,
Tag,
Sparkles,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import Editor from "@monaco-editor/react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Skill {
id: string;
name: string;
slug: string;
description: string | null;
namespace: string;
status: string;
triggerType: string | null;
version: string;
body: string | null;
parametersSchema: Record<string, unknown> | null;
tags: string[] | null;
extends: string[] | null;
createdAt: string;
updatedAt: string;
}
interface SkillExecution {
id: string;
skillId: string;
status: string;
triggeredBy: string;
inputParams: Record<string, unknown> | null;
outputResult: Record<string, unknown> | null;
errorMessage: string | null;
durationMs: number | null;
startedAt: string;
completedAt: string | null;
}
interface MarketplaceSkill extends Skill {
imported: boolean;
}
const EMPTY_SKILL = {
name: "",
slug: "",
description: "",
namespace: "tenant",
status: "draft",
triggerType: "manual",
body: "# Skill\n\nDescreva o comportamento desta skill aqui.\n\n## Entradas\n\n/var/input\n\n## Processamento\n\n...\n\n## Saída\n\n...",
parametersSchema: "{}",
tags: "",
extends: "",
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function statusBadge(status: string) {
const map: Record<string, string> = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
return map[status] ?? map.draft;
}
function execStatusIcon(status: string) {
if (status === "success") return <CheckCircle className="w-4 h-4 text-green-400" />;
if (status === "error") return <XCircle className="w-4 h-4 text-red-400" />;
return <Clock className="w-4 h-4 text-yellow-400 animate-spin" />;
}
function toSlug(name: string) {
return name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_|_$/g, "");
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Skills() {
const qc = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [nsFilter, setNsFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [editOpen, setEditOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [form, setForm] = useState(EMPTY_SKILL);
const [execOpen, setExecOpen] = useState(false);
const [execSkill, setExecSkill] = useState<Skill | null>(null);
const [execParams, setExecParams] = useState("{}");
const [execResult, setExecResult] = useState<SkillExecution | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
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
const skillsRef = useRef<Skill[]>([]);
const completionDisposable = useRef<{ dispose(): void } | null>(null);
// ── Queries ────────────────────────────────────────────────────────────────
const { data: skillsData, isLoading } = useQuery<{ skills: Skill[] }>({
queryKey: ["/api/skills", search, nsFilter, statusFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (nsFilter !== "all") params.set("namespace", nsFilter);
if (statusFilter !== "all") params.set("status", statusFilter);
const res = await fetch(`/api/skills?${params}`);
return res.json();
},
});
const { data: historyData } = useQuery<{ executions: SkillExecution[] }>({
queryKey: ["/api/skills", historySkill?.id, "executions"],
queryFn: async () => {
const res = await fetch(`/api/skills/${historySkill!.id}/executions?limit=20`);
return res.json();
},
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({
mutationFn: async (data: typeof form) => {
let paramsSchema: Record<string, unknown> = {};
try { paramsSchema = JSON.parse(data.parametersSchema); } catch {}
const payload = {
name: data.name,
slug: data.slug || toSlug(data.name),
description: data.description || null,
namespace: data.namespace,
status: data.status,
triggerType: data.triggerType,
body: data.body,
parametersSchema: paramsSchema,
tags: data.tags ? data.tags.split(",").map(t => t.trim()).filter(Boolean) : [],
extends: data.extends ? data.extends.split(",").map(t => t.trim()).filter(Boolean) : [],
};
const url = editingSkill ? `/api/skills/${editingSkill.id}` : "/api/skills";
const method = editingSkill ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/skills"] });
setEditOpen(false);
toast({ title: editingSkill ? "Skill atualizada" : "Skill criada" });
},
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/skills/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Erro ao deletar");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/skills"] });
toast({ title: "Skill removida" });
},
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<string, unknown> = {};
try { inputParams = JSON.parse(params); } catch {}
const res = await fetch(`/api/skills/${skillId}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputParams, triggeredBy: "manual" }),
});
return res.json() as Promise<SkillExecution>;
},
onSuccess: (data) => {
setExecResult(data);
qc.invalidateQueries({ queryKey: ["/api/skills"] });
},
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
});
// ── Handlers ───────────────────────────────────────────────────────────────
function openNew() {
setEditingSkill(null);
setForm(EMPTY_SKILL);
setEditOpen(true);
}
function openEdit(skill: Skill) {
setEditingSkill(skill);
setForm({
name: skill.name,
slug: skill.slug,
description: skill.description ?? "",
namespace: skill.namespace,
status: skill.status,
triggerType: skill.triggerType ?? "manual",
body: skill.body ?? "",
parametersSchema: JSON.stringify(skill.parametersSchema ?? {}, null, 2),
tags: (skill.tags ?? []).join(", "),
extends: (skill.extends ?? []).join(", "),
});
setEditOpen(true);
}
function openExec(skill: Skill) {
setExecSkill(skill);
setExecParams("{}");
setExecResult(null);
setExecOpen(true);
}
function openHistory(skill: Skill) {
setHistorySkill(skill);
setHistoryOpen(true);
}
const skills = skillsData?.skills ?? [];
// Mantém ref atualizado para o completion provider
useEffect(() => { skillsRef.current = skills; }, [skills]);
// Cleanup do completion provider ao desmontar
useEffect(() => () => { completionDisposable.current?.dispose(); }, []);
// Handler do Monaco Body — registra completion provider de referências /
function handleBodyEditorMount(editor: unknown, monaco: any) {
completionDisposable.current?.dispose();
const PREFIXES = [
{ label: "/skill/", detail: "Skill do tenant atual" },
{ label: "/skill:system/", detail: "Skill global do sistema" },
{ label: "/skill:company/", detail: "Skill da empresa" },
{ label: "/var/", detail: "Variável de contexto ou parâmetro de entrada" },
{ label: "/data/", detail: "Dado dinâmico do banco" },
{ label: "/kg/", detail: "Nó do Knowledge Graph (Neo4j)" },
{ label: "/file/", detail: "Arquivo no storage" },
{ label: "/tool/", detail: "Tool disponível no Manus" },
{ label: "/agent/", detail: "Agente especialista" },
];
completionDisposable.current = monaco.languages.registerCompletionItemProvider("markdown", {
triggerCharacters: ["/"],
provideCompletionItems(model: any, position: any) {
const line = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const match = line.match(/\/([\w:\/\-.]*)$/);
if (!match) return { suggestions: [] };
const typed = match[1];
const startCol = position.column - typed.length - 1;
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: startCol,
endColumn: position.column,
};
const CIK = monaco.languages.CompletionItemKind;
// Sugestões de prefixos
const prefixSuggestions = PREFIXES.map(p => ({
label: p.label,
kind: CIK.Reference,
detail: p.detail,
insertText: p.label,
sortText: "0_" + p.label,
range,
}));
// Sugestões de skills existentes quando digita /skill/...
const skillSuggestions: any[] = [];
if (typed.startsWith("skill/") || typed.startsWith("skill:")) {
const afterSkill = typed.replace(/^skill(:[^/]+)?\//, "");
skillsRef.current.forEach(s => {
const ref = `/skill/${s.namespace}/${s.slug}`;
if (!afterSkill || s.slug.includes(afterSkill) || s.name.toLowerCase().includes(afterSkill)) {
skillSuggestions.push({
label: ref,
kind: CIK.Variable,
detail: s.description ?? s.name,
documentation: `namespace: ${s.namespace} | status: ${s.status}`,
insertText: ref,
sortText: "1_" + ref,
range,
});
}
});
}
return { suggestions: [...prefixSuggestions, ...skillSuggestions] };
},
});
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<BrowserFrame>
<div className="flex flex-col h-full bg-[#0a0f1a] text-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<h1 className="text-lg font-semibold">Skills</h1>
<p className="text-xs text-muted-foreground">Objetos reutilizáveis herança, composição, polimorfismo</p>
</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">
<Plus className="w-4 h-4 mr-1" /> Nova Skill
</Button>
)}
</div>
</div>
{/* ── VIEW: MINHAS SKILLS ──────────────────────────────────────────────── */}
{view === "minhas" && (<>
{/* 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 skills..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
/>
</div>
<Select value={nsFilter} onValueChange={setNsFilter}>
<SelectTrigger className="w-32 h-8 text-xs bg-white/5 border-white/10">
<SelectValue placeholder="Namespace" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="tenant">Tenant</SelectItem>
<SelectItem value="company">Company</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-28 h-8 text-xs bg-white/5 border-white/10">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="active">Ativo</SelectItem>
<SelectItem value="draft">Rascunho</SelectItem>
<SelectItem value="archived">Arquivado</SelectItem>
</SelectContent>
</Select>
</div>
{/* List */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-[#c89b3c]" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground gap-2">
<Zap className="w-8 h-8 opacity-30" />
<p className="text-sm">Nenhuma skill encontrada</p>
<Button size="sm" variant="outline" onClick={openNew}>Criar primeira skill</Button>
</div>
) : (
<div className="p-3 space-y-2">
{skills.map(skill => (
<Card key={skill.id} className="bg-white/5 border-white/10 hover:bg-white/8 transition-colors">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">{skill.name}</span>
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${statusBadge(skill.status)}`}>
{skill.status}
</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-white/20 text-white/50">
{skill.namespace}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">/skill/{skill.namespace}/{skill.slug}</p>
{skill.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{skill.description}</p>
)}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{skill.tags.map(tag => (
<span key={tag} className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded">{tag}</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-green-500/20 hover:text-green-400"
onClick={() => openExec(skill)}
title="Executar"
>
<Play className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-blue-500/20 hover:text-blue-400"
onClick={() => openHistory(skill)}
title="Histórico"
>
<History className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-white/10"
onClick={() => openEdit(skill)}
title="Editar"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-red-500/20 hover:text-red-400"
onClick={() => deleteMutation.mutate(skill.id)}
title="Deletar"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</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>
{/* ── Edit / Create Dialog ────────────────────────────────────────────── */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-3xl h-[85vh] flex flex-col bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle>{editingSkill ? "Editar Skill" : "Nova Skill"}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="form" className="flex-1 flex flex-col min-h-0">
<TabsList className="bg-white/5 border border-white/10 w-fit">
<TabsTrigger value="form" className="text-xs gap-1">
<ListChecks className="w-3.5 h-3.5" /> Geral
</TabsTrigger>
<TabsTrigger value="body" className="text-xs gap-1">
<Code2 className="w-3.5 h-3.5" /> Body
</TabsTrigger>
<TabsTrigger value="params" className="text-xs gap-1">
<ChevronRight className="w-3.5 h-3.5" /> Parâmetros
</TabsTrigger>
</TabsList>
{/* Tab: Geral */}
<TabsContent value="form" className="flex-1 overflow-auto mt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Nome *</Label>
<Input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value, slug: toSlug(e.target.value) }))}
placeholder="Relatório de Vendas"
className="bg-white/5 border-white/10 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Slug</Label>
<Input
value={form.slug}
onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
placeholder="relatorio_vendas"
className="bg-white/5 border-white/10 text-sm font-mono"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Descrição</Label>
<Textarea
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={2}
className="bg-white/5 border-white/10 text-sm resize-none"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs">Namespace</Label>
<Select value={form.namespace} onValueChange={v => setForm(f => ({ ...f, namespace: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">system</SelectItem>
<SelectItem value="tenant">tenant</SelectItem>
<SelectItem value="company">company</SelectItem>
<SelectItem value="user">user</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Status</Label>
<Select value={form.status} onValueChange={v => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">draft</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="archived">archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Trigger</Label>
<Select value={form.triggerType} onValueChange={v => setForm(f => ({ ...f, triggerType: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">manual</SelectItem>
<SelectItem value="schedule">schedule</SelectItem>
<SelectItem value="event">event</SelectItem>
<SelectItem value="webhook">webhook</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Tags (separadas por vírgula)</Label>
<Input
value={form.tags}
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="financeiro, relatório, automático"
className="bg-white/5 border-white/10 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Herança extends (slugs separados por vírgula)</Label>
<Input
value={form.extends}
onChange={e => setForm(f => ({ ...f, extends: e.target.value }))}
placeholder="/skill/system/base_report, /skill/tenant/financeiro"
className="bg-white/5 border-white/10 text-sm font-mono"
/>
</div>
</TabsContent>
{/* Tab: Body (Monaco) */}
<TabsContent value="body" className="flex-1 min-h-0 mt-3">
<div className="h-full rounded border border-white/10 overflow-hidden">
<Editor
height="100%"
defaultLanguage="markdown"
value={form.body}
onChange={v => setForm(f => ({ ...f, body: v ?? "" }))}
onMount={handleBodyEditorMount}
theme="vs-dark"
options={{
fontSize: 13,
minimap: { enabled: false },
wordWrap: "on",
lineNumbers: "on",
scrollBeyondLastLine: false,
padding: { top: 8 },
quickSuggestions: { other: true, comments: true, strings: true },
suggestOnTriggerCharacters: true,
}}
/>
</div>
</TabsContent>
{/* Tab: Params (JSON Schema) */}
<TabsContent value="params" className="flex-1 min-h-0 mt-3">
<div className="space-y-2 h-full flex flex-col">
<p className="text-xs text-muted-foreground">
JSON Schema dos parâmetros de entrada. Referenciáveis no body como <code className="bg-white/10 px-1 rounded">/var/nome_param</code>.
</p>
<div className="flex-1 rounded border border-white/10 overflow-hidden">
<Editor
height="100%"
defaultLanguage="json"
value={form.parametersSchema}
onChange={v => setForm(f => ({ ...f, parametersSchema: v ?? "{}" }))}
theme="vs-dark"
options={{
fontSize: 13,
minimap: { enabled: false },
formatOnType: true,
scrollBeyondLastLine: false,
padding: { top: 8 },
}}
/>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 pt-3 border-t border-white/10 mt-2">
<Button variant="ghost" onClick={() => setEditOpen(false)}>Cancelar</Button>
<Button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending || !form.name}
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black"
>
{saveMutation.isPending && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{editingSkill ? "Salvar" : "Criar"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ── Execute Dialog ──────────────────────────────────────────────────── */}
<Dialog open={execOpen} onOpenChange={setExecOpen}>
<DialogContent className="max-w-lg bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Play className="w-4 h-4 text-green-400" />
Executar: {execSkill?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs mb-1 block">Parâmetros de entrada (JSON)</Label>
<div className="rounded border border-white/10 overflow-hidden h-32">
<Editor
height="100%"
defaultLanguage="json"
value={execParams}
onChange={v => setExecParams(v ?? "{}")}
theme="vs-dark"
options={{ fontSize: 12, minimap: { enabled: false }, scrollBeyondLastLine: false, padding: { top: 4 } }}
/>
</div>
</div>
{execResult && (
<div className="rounded border border-white/10 p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
{execStatusIcon(execResult.status)}
{execResult.status === "success" ? "Executado com sucesso" : "Erro na execução"}
{execResult.durationMs && (
<span className="text-xs text-muted-foreground ml-auto">{execResult.durationMs}ms</span>
)}
</div>
{execResult.status === "error" && execResult.errorMessage && (
<p className="text-xs text-red-400 font-mono">{execResult.errorMessage}</p>
)}
{execResult.outputResult && (
<ScrollArea className="h-40">
<pre className="text-xs text-green-300 font-mono whitespace-pre-wrap">
{JSON.stringify(execResult.outputResult, null, 2)}
</pre>
</ScrollArea>
)}
<Button
size="sm"
variant="ghost"
className="text-xs"
onClick={() => navigator.clipboard.writeText(JSON.stringify(execResult.outputResult, null, 2))}
>
<Copy className="w-3 h-3 mr-1" /> Copiar resultado
</Button>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
<Button variant="ghost" size="sm" onClick={() => setExecOpen(false)}>Fechar</Button>
<Button
size="sm"
onClick={() => execSkill && executeMutation.mutate({ skillId: execSkill.id, params: execParams })}
disabled={executeMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
{executeMutation.isPending
? <><Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> Executando...</>
: <><Play className="w-3.5 h-3.5 mr-1" /> Executar</>
}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ── History Dialog ──────────────────────────────────────────────────── */}
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
<DialogContent className="max-w-2xl bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
Histórico: {historySkill?.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-80">
{!historyData ? (
<div className="flex items-center justify-center h-20">
<Loader2 className="w-5 h-5 animate-spin text-[#c89b3c]" />
</div>
) : historyData.executions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">Nenhuma execução registrada</p>
) : (
<div className="space-y-2 p-1">
{historyData.executions.map(ex => (
<div key={ex.id} className="flex items-start gap-3 p-2.5 rounded bg-white/5 border border-white/10">
<div className="mt-0.5">{execStatusIcon(ex.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground truncate">{ex.id.slice(0, 8)}...</span>
<Badge variant="outline" className="text-[10px] px-1 border-white/20 text-white/50">
{ex.triggeredBy}
</Badge>
{ex.durationMs && (
<span className="text-[10px] text-muted-foreground ml-auto">{ex.durationMs}ms</span>
)}
</div>
{ex.errorMessage && (
<p className="text-xs text-red-400 font-mono truncate">{ex.errorMessage}</p>
)}
<p className="text-[10px] text-muted-foreground">
{new Date(ex.startedAt).toLocaleString("pt-BR")}
</p>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</BrowserFrame>
);
}