From 35ca1f80f2eba2dc618cd96aff594a042fff5435 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:56:55 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- .../mika/skills/SkillMarkdownEditor.tsx | 59 +++ src/routes/painel.agente.tsx | 180 ++++++++ src/routes/painel.skills.$id.tsx | 411 ++++++++++++++++++ src/routes/painel.skills.index.tsx | 128 ++++++ src/routes/painel.skills.nova.tsx | 267 ++++++++++++ src/routes/painel.skills.preview.tsx | 245 +++++++++++ src/routes/painel.skills.tsx | 12 + 7 files changed, 1302 insertions(+) create mode 100644 src/components/mika/skills/SkillMarkdownEditor.tsx create mode 100644 src/routes/painel.agente.tsx create mode 100644 src/routes/painel.skills.$id.tsx create mode 100644 src/routes/painel.skills.index.tsx create mode 100644 src/routes/painel.skills.nova.tsx create mode 100644 src/routes/painel.skills.preview.tsx create mode 100644 src/routes/painel.skills.tsx 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/routes/painel.agente.tsx b/src/routes/painel.agente.tsx new file mode 100644 index 0000000..b71d7ab --- /dev/null +++ b/src/routes/painel.agente.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Bot, Cpu, MessageSquare, Sparkles, BarChart3, Loader2 } from "lucide-react"; +import { useProfile } from "@/hooks/use-profile"; +import { useAgentInstance } from "@/hooks/use-agent-instance"; +import { useUserSkillLimits } from "@/hooks/use-user-skill-limits"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel/agente")({ + component: AgentePage, +}); + +const STATUS_MAP: Record = { + provisioning: { label: "Provisionando", color: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", pulse: true }, + active: { label: "Online", color: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30" }, + suspended: { label: "Suspenso", color: "bg-destructive/15 text-destructive border-destructive/30" }, + error: { label: "Erro", color: "bg-destructive/15 text-destructive border-destructive/30" }, +}; + +function AgentePage() { + const { data: profile } = useProfile(); + const agent = useAgentInstance(); + const limits = useUserSkillLimits(); + const { user } = useAuth(); + + const lastTestRun = useQuery({ + queryKey: ["last-test-run", user?.id], + enabled: !!user, + queryFn: async () => { + const { data, error } = await supabase + .from("skill_test_runs") + .select("created_at") + .eq("user_id", user!.id) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw error; + return data?.created_at ?? null; + }, + }); + + const loading = agent.isLoading; + + if (loading) { + return ( +
+ + +
+ ); + } + + const firstName = (profile?.full_name || "").split(" ")[0] || "Você"; + const agentName = `Mika de ${firstName}`; + const status = agent.data?.status ?? "provisioning"; + const statusInfo = STATUS_MAP[status] ?? STATUS_MAP.provisioning; + + return ( +
+
+

Meu Agente

+

+ Gerencie seu agente pessoal de IA +

+
+ +
+ {/* Card 1: Seu agente — full width */} +
+
+
+ +
+
+
+

{agentName}

+ + {statusInfo.label} + +
+

+ {status === "provisioning" + ? "Estamos preparando sua instância. Geralmente leva até 10 minutos." + : status === "active" + ? "Seu agente está online e pronto para receber skills." + : "Houve um problema com sua instância. Entre em contato com o suporte."} +

+
+
+
+ + {/* Card 2: Modelo de IA */} +
+
+ +

Modelo de IA

+
+

Opencode Zen

+ + Padrão do plano + +
+ + {/* Card 3: Canais conectados */} +
+
+ +

Canais conectados

+
+
+
+ Telegram +
+ + + + + Disponível em breve + +
+
+ + {/* Card 4: Estatísticas */} +
+
+ +

Estatísticas

+
+
+ + + + +
+
+
+
+ ); +} + +function StatItem({ label, value }: { label: string; value: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +} diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx new file mode 100644 index 0000000..3a63fee --- /dev/null +++ b/src/routes/painel.skills.$id.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"; +import { useState, useCallback, lazy, Suspense } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { toast } from "sonner"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useSkill } from "@/hooks/use-skills"; +import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge"; +import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor")); + +export const Route = createFileRoute("/painel/skills/$id")({ + component: SkillDetailPage, +}); + +interface SkillVersion { + id: string; + version_number: number; + markdown_content: string; + form_inputs: Record; + is_live: boolean; + created_at: string; +} + +function SkillDetailPage() { + const { id } = Route.useParams(); + const { user } = useAuth(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const skill = useSkill(id); + + const versions = useQuery({ + queryKey: ["skill-versions", id], + enabled: !!id, + queryFn: async (): Promise => { + const { data, error } = await supabase + .from("skill_versions") + .select("id, version_number, markdown_content, form_inputs, is_live, created_at") + .eq("skill_id", id) + .order("version_number", { ascending: false }) + .limit(12); + if (error) throw error; + return (data ?? []) as SkillVersion[]; + }, + }); + + const [selectedVersionId, setSelectedVersionId] = useState(null); + const [markdown, setMarkdown] = useState(""); + const [editing, setEditing] = useState(false); + const [testOpen, setTestOpen] = useState(false); + const [confirmArchive, setConfirmArchive] = useState(false); + + // Sync markdown when versions load or selection changes + const currentVersion = versions.data?.find((v) => + selectedVersionId ? v.id === selectedVersionId : v.is_live, + ) ?? versions.data?.[0]; + + if (currentVersion && markdown === "" && !editing) { + // initial load + setTimeout(() => setMarkdown(currentVersion.markdown_content), 0); + } + + const selectVersion = useCallback( + (v: SkillVersion) => { + setSelectedVersionId(v.id); + setMarkdown(v.markdown_content); + setEditing(false); + }, + [], + ); + + // Save new version + const saveVersion = useMutation({ + mutationFn: async () => { + if (!user || !versions.data) throw new Error("Dados indisponíveis"); + const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0); + const { data, error } = await supabase + .from("skill_versions") + .insert({ + skill_id: id, + version_number: maxVer + 1, + markdown_content: markdown, + form_inputs: currentVersion?.form_inputs ?? {}, + is_live: false, + created_by: user.id, + }) + .select("id, version_number") + .single(); + if (error) throw error; + return data; + }, + onSuccess: (data) => { + toast.success(`Versão ${data.version_number} salva`); + setSelectedVersionId(data.id); + setEditing(false); + qc.invalidateQueries({ queryKey: ["skill-versions", id] }); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao salvar"), + }); + + // Publish version + const publishVersion = useMutation({ + mutationFn: async (versionId: string) => { + const { data, error } = await supabase.functions.invoke("publish-skill-version", { + body: { skill_version_id: versionId }, + }); + if (error) throw error; + return data; + }, + onSuccess: (data) => { + if (data.no_op) { + toast.info("Esta versão já está publicada"); + } else { + toast.success(`Versão ${data.version_number} publicada!`); + } + qc.invalidateQueries({ queryKey: ["skill-versions", id] }); + qc.invalidateQueries({ queryKey: ["skill", id] }); + qc.invalidateQueries({ queryKey: ["skills"] }); + }, + onError: (e: unknown) => { + const msg = e instanceof Error ? e.message : "Erro"; + if (msg.includes("409")) { + toast.error("Conflito de concorrência. Recarregue e tente novamente."); + } else { + toast.error(msg); + } + }, + }); + + // Archive + const archiveSkill = useMutation({ + mutationFn: async () => { + const { error } = await supabase + .from("skills") + .update({ status: "archived", updated_at: new Date().toISOString() }) + .eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Skill arquivada"); + qc.invalidateQueries({ queryKey: ["skills"] }); + qc.invalidateQueries({ queryKey: ["user-limits"] }); + navigate({ to: "/painel/skills" }); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"), + }); + + const loading = skill.isLoading || versions.isLoading; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!skill.data) { + return ( +
+

Skill não encontrada.

+ +
+ ); + } + + const isCurrentLive = currentVersion?.is_live === true; + const hasChanged = editing && markdown !== currentVersion?.markdown_content; + + return ( +
+ {/* Header */} +
+
+ +
+

{skill.data.name}

+
+ + {currentVersion && ( + + v{currentVersion.version_number} + {currentVersion.is_live && " (live)"} + + )} +
+
+
+
+ + + {editing && hasChanged && ( + + )} + + {!editing && currentVersion && !isCurrentLive && ( + + )} + + {!editing && ( + + )} + + + + + + + + Duplicar + + + setConfirmArchive(true)} + className="text-destructive focus:text-destructive cursor-pointer" + > + Arquivar + + + +
+
+ + {/* Main content */} +
+ {/* Editor / Preview (3 cols) */} +
+ {/* Desktop split */} +
+
+ }> + { setMarkdown(v); if (!editing) setEditing(true); }} + readOnly={!editing} + /> + +
+
+ {markdown} +
+
+ {/* Mobile tabs */} +
+ + + Editor + Preview + + +
+ }> + { setMarkdown(v); if (!editing) setEditing(true); }} + readOnly={!editing} + /> + +
+
+ +
+ {markdown} +
+
+
+
+
+ + {/* Timeline sidebar (1 col) */} +
+
+

Versões

+ {versions.data?.map((v) => ( + + )} + + ))} +
+
+
+ + {/* Test panel */} + {testOpen && currentVersion && ( + + )} + + {/* Archive dialog */} + + + + Arquivar esta skill? + + A skill ficará invisível para o agente. Você poderá restaurá-la depois. + + + + Cancelar + archiveSkill.mutate()}>Arquivar + + + +
+ ); +} diff --git a/src/routes/painel.skills.index.tsx b/src/routes/painel.skills.index.tsx new file mode 100644 index 0000000..5c5f32b --- /dev/null +++ b/src/routes/painel.skills.index.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { Plus, Sparkles } from "lucide-react"; +import { useSkills } from "@/hooks/use-skills"; +import { useUserSkillLimits } from "@/hooks/use-user-skill-limits"; +import { useAgentInstance } from "@/hooks/use-agent-instance"; +import { NoSubscriptionState } from "@/components/mika/skills/NoSubscriptionState"; +import { AgentProvisioningState } from "@/components/mika/skills/AgentProvisioningState"; +import { EmptySkillsState } from "@/components/mika/skills/EmptySkillsState"; +import { SkillCard } from "@/components/mika/skills/SkillCard"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export const Route = createFileRoute("/painel/skills/")({ + component: SkillsPage, +}); + +function SkillsPage() { + const [showArchived, setShowArchived] = useState(false); + const limits = useUserSkillLimits(); + const agent = useAgentInstance(); + const skills = useSkills(showArchived); + + const loading = limits.isLoading || agent.isLoading || skills.isLoading; + const noSub = !limits.isLoading && (limits.data?.max_skills == null); + const agentNotReady = + !agent.isLoading && + (!agent.data || agent.data.status === "provisioning"); + const atLimit = + limits.data != null && + limits.data.max_skills != null && + limits.data.current_skills_count >= limits.data.max_skills; + + if (loading) { + return ( +
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + + return ( +
+
+
+

Skills

+

+ Crie automações personalizadas para seu agente Mika +

+
+ + {!noSub && ( +
+ {limits.data && limits.data.max_skills != null && ( + + {limits.data.current_skills_count} de {limits.data.max_skills} skills + + )} + +
+ + +
+ + + + + + + + {(atLimit || agentNotReady) && ( + + {agentNotReady + ? "Aguarde o provisionamento do agente terminar" + : `Limite de ${limits.data?.max_skills} skills do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`} + + )} + +
+ )} +
+ + {noSub ? ( + + ) : agentNotReady ? ( + + ) : skills.data && skills.data.length === 0 ? ( + + ) : ( +
+ {skills.data?.map((skill) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/routes/painel.skills.nova.tsx b/src/routes/painel.skills.nova.tsx new file mode 100644 index 0000000..6eabb60 --- /dev/null +++ b/src/routes/painel.skills.nova.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeft, Check, CircleDashed, Loader2, Sparkles, X } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { supabase } from "@/integrations/supabase/client"; +import { skillFormSchema, AVAILABLE_TOOLS, type SkillFormValues } from "@/lib/skill-schema"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel/skills/nova")({ + component: NovaSkillPage, +}); + +const FIELDS_META: { key: keyof SkillFormValues; label: string; required: boolean }[] = [ + { key: "name", label: "Nome", required: true }, + { key: "description", label: "Descrição", required: true }, + { key: "trigger_keywords", label: "Gatilhos", required: true }, + { key: "expected_inputs", label: "Inputs esperados", required: false }, + { key: "steps", label: "Passo a passo", required: true }, + { key: "required_tools", label: "Ferramentas", required: true }, + { key: "success_criteria", label: "Critério de sucesso", required: true }, + { key: "example_use_case", label: "Exemplo de uso", required: false }, +]; + +function NovaSkillPage() { + const navigate = useNavigate(); + const [generating, setGenerating] = useState(false); + + const form = useForm({ + resolver: zodResolver(skillFormSchema), + defaultValues: { + name: "", + description: "", + trigger_keywords: "", + expected_inputs: "", + steps: "", + required_tools: [], + success_criteria: "", + example_use_case: "", + }, + mode: "onChange", + }); + + const values = form.watch(); + const { isValid } = form.formState; + + const fieldFilled = (key: keyof SkillFormValues): boolean => { + const v = values[key]; + if (key === "required_tools") return Array.isArray(v) && v.length > 0; + return typeof v === "string" && v.trim().length > 0; + }; + + const handleGenerate = async () => { + const valid = await form.trigger(); + if (!valid) return; + setGenerating(true); + try { + const formValues = form.getValues(); + const formInputs = { + name: formValues.name, + description: formValues.description, + trigger_keywords: formValues.trigger_keywords, + expected_inputs: formValues.expected_inputs || null, + steps: formValues.steps, + required_tools: formValues.required_tools, + success_criteria: formValues.success_criteria, + example_use_case: formValues.example_use_case || null, + }; + + const { data, error } = await supabase.functions.invoke("generate-skill-markdown", { + body: { form_inputs: formInputs }, + }); + + if (error) { + const msg = error.message || "Falha ao gerar skill"; + if (msg.includes("429") || msg.includes("Muitas")) { + toast.error("Muitas requisições. Aguarde 1 minuto e tente novamente."); + } else { + toast.error(msg); + } + return; + } + + // Navigate to preview with state + navigate({ + to: "/painel/skills/preview", + search: {}, + state: { + markdown_content: data.markdown_content, + form_inputs: formInputs, + } as Record, + }); + } catch (e) { + toast.error("Erro inesperado ao gerar skill. Tente novamente."); + } finally { + setGenerating(false); + } + }; + + return ( +
+
+ +
+

Nova skill

+

+ Preencha os campos e gere o conteúdo com IA +

+
+
+ +
+ {/* Form column */} +
+ + + + + +