diff --git a/bun.lockb b/bun.lockb index 7d464b6..6380595 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 2bd89c9..1104cf5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.25.5", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/theme-one-dark": "^6.1.3", "@hookform/resolvers": "^5.2.2", "@lovable.dev/cloud-auth-js": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", @@ -47,6 +49,7 @@ "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", + "@uiw/react-codemirror": "^4.25.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -60,8 +63,10 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.72.1", "react-imask": "^7.6.1", + "react-markdown": "^10.1.0", "react-resizable-panels": "^4.6.5", "recharts": "^2.15.4", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", 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/SkillMarkdownEditor.tsx b/src/components/mika/skills/SkillMarkdownEditor.tsx new file mode 100644 index 0000000..082b1bc --- /dev/null +++ b/src/components/mika/skills/SkillMarkdownEditor.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import CodeMirror from "@uiw/react-codemirror"; +import { markdown as markdownLang } from "@codemirror/lang-markdown"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { EditorView } from "@codemirror/view"; + +interface Props { + value: string; + onChange: (val: string) => void; + readOnly?: boolean; +} + +// Light theme — matches Mika design system +const mikaLight = EditorView.theme({ + "&": { backgroundColor: "var(--color-card)", color: "var(--color-foreground)" }, + ".cm-content": { caretColor: "var(--color-primary)" }, + ".cm-activeLine": { backgroundColor: "var(--color-muted)" }, + ".cm-selectionBackground, ::selection": { backgroundColor: "oklch(0.70 0.19 47 / 0.25) !important" }, + ".cm-gutters": { backgroundColor: "var(--color-card)", borderRight: "1px solid var(--color-border)" }, +}); + +export default function SkillMarkdownEditor({ value, onChange, readOnly = false }: Props) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const html = document.documentElement; + const check = () => setIsDark(html.classList.contains("dark")); + check(); + const obs = new MutationObserver(check); + obs.observe(html, { attributes: true, attributeFilter: ["class"] }); + return () => obs.disconnect(); + }, []); + + const extensions = useMemo( + () => [markdownLang(), EditorView.lineWrapping], + [], + ); + + const handleChange = useCallback( + (val: string) => onChange(val), + [onChange], + ); + + return ( + + ); +} 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} + + + +
+
+ +