diff --git a/src/components/mika/skills/AgentProvisioningState.tsx b/src/components/mika/skills/AgentProvisioningState.tsx new file mode 100644 index 0000000..062094b --- /dev/null +++ b/src/components/mika/skills/AgentProvisioningState.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Hourglass } from "lucide-react"; + +export function AgentProvisioningState() { + return ( +
+
+ +
+

Aguardando seu agente ficar pronto

+

+ Estamos provisionando sua instância do Mika. Você poderá criar skills assim + que o provisionamento terminar — geralmente em até 10 minutos. +

+
+ ); +} diff --git a/src/components/mika/skills/EmptySkillsState.tsx b/src/components/mika/skills/EmptySkillsState.tsx new file mode 100644 index 0000000..2ea0259 --- /dev/null +++ b/src/components/mika/skills/EmptySkillsState.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Sparkles } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; + +export function EmptySkillsState({ disabled }: { disabled?: boolean }) { + return ( +
+
+ +
+

Você ainda não tem skills personalizadas

+

+ Crie sua primeira skill em 2 minutos e ensine seu agente Mika a fazer + exatamente o que você precisa. +

+ +
+ ); +} diff --git a/src/components/mika/skills/NoSubscriptionState.tsx b/src/components/mika/skills/NoSubscriptionState.tsx new file mode 100644 index 0000000..fb50df6 --- /dev/null +++ b/src/components/mika/skills/NoSubscriptionState.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Sparkles } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; + +export function NoSubscriptionState() { + return ( +
+
+ +
+

+ Você precisa de uma assinatura ativa para criar skills +

+

+ Escolha um plano para liberar o Skill Studio e começar a personalizar + seu agente Mika com automações próprias. +

+ +
+ ); +} diff --git a/src/components/mika/skills/SkillCard.tsx b/src/components/mika/skills/SkillCard.tsx new file mode 100644 index 0000000..09de28e --- /dev/null +++ b/src/components/mika/skills/SkillCard.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { MoreVertical, Edit, Play, Power, Copy, Archive, RotateCcw, Trash2 } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { toast } from "sonner"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { SkillStatusBadge } from "./SkillStatusBadge"; +import type { Skill } from "@/hooks/use-skills"; +import { SkillTestPanel } from "./SkillTestPanel"; + +export function SkillCard({ skill }: { skill: Skill }) { + const navigate = useNavigate(); + const qc = useQueryClient(); + const [confirmArchive, setConfirmArchive] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [testOpen, setTestOpen] = useState(false); + + const isArchived = skill.status === "archived"; + + const updateStatus = useMutation({ + mutationFn: async (newStatus: string) => { + const { error } = await supabase + .from("skills") + .update({ status: newStatus, updated_at: new Date().toISOString() }) + .eq("id", skill.id); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["skills"] }); + qc.invalidateQueries({ queryKey: ["user-limits"] }); + }, + }); + + const handleToggleActive = () => { + const next = skill.status === "active" ? "disabled" : "active"; + updateStatus.mutate(next, { + onSuccess: () => toast.success(next === "active" ? "Skill ativada" : "Skill desativada"), + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"), + }); + }; + + const handleArchive = () => { + updateStatus.mutate("archived", { + onSuccess: () => { + toast.success("Skill arquivada"); + setConfirmArchive(false); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao arquivar"), + }); + }; + + const handleRestore = () => { + updateStatus.mutate("draft", { + onSuccess: () => toast.success("Skill restaurada como rascunho"), + onError: (e: unknown) => { + if ((e as { code?: string })?.code === "23505") { + toast.error("Já existe outra skill ativa com esse nome. Renomeie antes de restaurar."); + } else { + toast.error(e instanceof Error ? e.message : "Erro ao restaurar"); + } + }, + }); + }; + + const handleDelete = useMutation({ + mutationFn: async () => { + const { error } = await supabase.from("skills").delete().eq("id", skill.id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Skill deletada"); + qc.invalidateQueries({ queryKey: ["skills"] }); + qc.invalidateQueries({ queryKey: ["user-limits"] }); + setConfirmDelete(false); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"), + }); + + return ( + <> +
+
+
+ +

+ {skill.description} +

+
+ + + + + + {isArchived ? ( + <> + + Restaurar + + + setConfirmDelete(true)} + className="cursor-pointer text-destructive focus:text-destructive" + > + Deletar + + + ) : ( + <> + + + Editar + + + setTestOpen(true)} + disabled={!skill.current_version_id} + className="cursor-pointer" + > + Testar + + + + {skill.status === "active" ? "Desativar" : "Ativar"} + + + Duplicar + + + setConfirmArchive(true)} + className="cursor-pointer text-destructive focus:text-destructive" + > + Arquivar + + + )} + + +
+ +
+ + + {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })} + +
+
+ + + + + Arquivar esta skill? + + A skill {skill.name} ficará invisível para o agente. Você + poderá restaurá-la depois sem perder o histórico. + + + + Cancelar + Arquivar + + + + + + + + Deletar definitivamente? + + Esta ação remove {skill.name} e todo o histórico de versões. + Não é possível desfazer. + + + + Cancelar + handleDelete.mutate()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Deletar + + + + + + {testOpen && skill.current_version_id && ( + + )} + + ); +} diff --git a/src/components/mika/skills/SkillStatusBadge.tsx b/src/components/mika/skills/SkillStatusBadge.tsx new file mode 100644 index 0000000..f83cfb9 --- /dev/null +++ b/src/components/mika/skills/SkillStatusBadge.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +const STATUS_LABELS: Record = { + draft: "Rascunho", + testing: "Em teste", + active: "Ativa", + disabled: "Desativada", + archived: "Arquivada", +}; + +const STATUS_CLASSES: Record = { + draft: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", + testing: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30", + active: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30", + disabled: "bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30", + archived: "bg-slate-500/10 text-slate-500/70 border-slate-500/20", +}; + +export function SkillStatusBadge({ status, className }: { status: string; className?: string }) { + return ( + + {STATUS_LABELS[status] ?? status} + + ); +} diff --git a/src/components/mika/skills/SkillTestPanel.tsx b/src/components/mika/skills/SkillTestPanel.tsx new file mode 100644 index 0000000..7ed2060 --- /dev/null +++ b/src/components/mika/skills/SkillTestPanel.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Play, X } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { supabase } from "@/integrations/supabase/client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + skillName: string; + skillVersionId: string; + triggerKeywords?: string; + // se não houver skill_version_id ainda (preview), passa o markdown direto e usa stateless mode + stateless?: { markdown_content: string }; +} + +interface TestResult { + status: "success" | "error"; + test_output?: string; + duration_ms: number; + error_message?: string; +} + +export function SkillTestPanel({ + open, + onOpenChange, + skillName, + skillVersionId, + triggerKeywords, + stateless, +}: Props) { + const [input, setInput] = useState(""); + const [result, setResult] = useState(null); + const inputRef = useRef(null); + const qc = useQueryClient(); + + useEffect(() => { + if (open) setTimeout(() => inputRef.current?.focus(), 50); + }, [open]); + + const placeholder = triggerKeywords + ? `Ex: "${triggerKeywords.split(",")[0]?.trim() || "..."}"` + : "Digite um exemplo de input que você daria ao Mika para acionar esta skill"; + + const history = useQuery({ + queryKey: ["skill-test-runs", skillVersionId], + enabled: open && !stateless, + queryFn: async () => { + const { data, error } = await supabase + .from("skill_test_runs") + .select("*") + .eq("skill_version_id", skillVersionId) + .order("created_at", { ascending: false }) + .limit(5); + if (error) throw error; + return data ?? []; + }, + }); + + const runTest = useMutation({ + mutationFn: async (): Promise => { + if (stateless) { + // Modo preview sem persistência: chama AI direto via edge function + const start = Date.now(); + const { data, error } = await supabase.functions.invoke("test-skill-dry-run", { + body: { skill_version_id: skillVersionId, test_input: input }, + }); + if (error) { + return { + status: "error", + duration_ms: Date.now() - start, + error_message: error.message, + }; + } + return data as TestResult; + } + const { data, error } = await supabase.functions.invoke("test-skill-dry-run", { + body: { skill_version_id: skillVersionId, test_input: input }, + }); + if (error) throw new Error(error.message); + return data as TestResult; + }, + onSuccess: (r) => { + setResult(r); + qc.invalidateQueries({ queryKey: ["skill-test-runs", skillVersionId] }); + }, + onError: (e: unknown) => { + setResult({ + status: "error", + duration_ms: 0, + error_message: e instanceof Error ? e.message : "Erro desconhecido", + }); + }, + }); + + return ( + + + + + + Testar skill: {skillName} + + + +
+
+ +