Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 18:56:55 +00:00
parent a32c05a679
commit 35ca1f80f2
7 changed files with 1302 additions and 0 deletions

View file

@ -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 (
<CodeMirror
value={value}
onChange={handleChange}
extensions={extensions}
theme={isDark ? oneDark : mikaLight}
readOnly={readOnly}
height="100%"
minHeight="300px"
maxHeight="80vh"
className="text-sm"
/>
);
}

View file

@ -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<string, { label: string; color: string; pulse?: boolean }> = {
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 (
<div className="space-y-4">
<Skeleton className="h-12 w-1/3" />
<Skeleton className="h-40 rounded-xl" />
</div>
);
}
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 (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold tracking-tight">Meu Agente</h1>
<p className="mt-1 text-muted-foreground">
Gerencie seu agente pessoal de IA
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Card 1: Seu agente — full width */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-start gap-4">
<div className={cn(
"h-14 w-14 rounded-full flex items-center justify-center shrink-0",
status === "active" ? "bg-emerald-500/10" : "bg-primary/10",
)}>
<Bot className={cn("h-7 w-7", status === "active" ? "text-emerald-500" : "text-primary")} />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-xl font-bold">{agentName}</h2>
<Badge
variant="outline"
className={cn(statusInfo.color, statusInfo.pulse && "animate-pulse")}
>
{statusInfo.label}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
</div>
</div>
</div>
{/* Card 2: Modelo de IA */}
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-center gap-3 mb-3">
<Cpu className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Modelo de IA</h3>
</div>
<p className="text-lg font-bold">Opencode Zen</p>
<Badge variant="outline" className="mt-2 text-xs">
Padrão do plano
</Badge>
</div>
{/* Card 3: Canais conectados */}
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-center gap-3 mb-3">
<MessageSquare className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Canais conectados</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Telegram</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" disabled>
Conectar
</Button>
</TooltipTrigger>
<TooltipContent>Disponível em breve</TooltipContent>
</Tooltip>
</div>
</div>
{/* Card 4: Estatísticas */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-center gap-3 mb-4">
<BarChart3 className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Estatísticas</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatItem label="Interações hoje" value="0" />
<StatItem
label="Skills ativas"
value={limits.isLoading ? "..." : String(limits.data?.current_skills_count ?? 0)}
/>
<StatItem
label="Último teste"
value={
lastTestRun.data
? formatDistanceToNow(new Date(lastTestRun.data), { addSuffix: true, locale: ptBR })
: "—"
}
/>
<StatItem label="Uptime" value="—" />
</div>
</div>
</div>
</div>
);
}
function StatItem({ label, value }: { label: string; value: string }) {
return (
<div className="text-center sm:text-left">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
);
}

View file

@ -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<string, unknown>;
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<SkillVersion[]> => {
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<string | null>(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 (
<div className="space-y-4">
<Skeleton className="h-10 w-1/3" />
<Skeleton className="h-[60vh] rounded-xl" />
</div>
);
}
if (!skill.data) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Skill não encontrada.</p>
<Button asChild variant="outline" className="mt-4">
<Link to="/painel/skills">Voltar</Link>
</Button>
</div>
);
}
const isCurrentLive = currentVersion?.is_live === true;
const hasChanged = editing && markdown !== currentVersion?.markdown_content;
return (
<div className="space-y-4">
{/* Header */}
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Button variant="ghost" size="icon" asChild>
<Link to="/painel/skills">
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div className="min-w-0">
<h1 className="text-xl font-bold truncate">{skill.data.name}</h1>
<div className="flex items-center gap-2 mt-0.5">
<SkillStatusBadge status={skill.data.status} />
{currentVersion && (
<span className="text-xs text-muted-foreground">
v{currentVersion.version_number}
{currentVersion.is_live && " (live)"}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => setTestOpen(true)}
disabled={!currentVersion}
>
<Play className="h-4 w-4 mr-1" /> Testar
</Button>
{editing && hasChanged && (
<Button
size="sm"
variant="outline"
onClick={() => saveVersion.mutate()}
disabled={saveVersion.isPending}
>
{saveVersion.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
Salvar nova versão
</Button>
)}
{!editing && currentVersion && !isCurrentLive && (
<Button
size="sm"
onClick={() => publishVersion.mutate(currentVersion.id)}
disabled={publishVersion.isPending}
className="bg-primary hover:bg-primary-dark text-primary-foreground"
>
{publishVersion.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-1" />
)}
Publicar esta versão
</Button>
)}
{!editing && (
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Editar
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled className="opacity-50">
<Copy className="h-4 w-4 mr-2" /> Duplicar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmArchive(true)}
className="text-destructive focus:text-destructive cursor-pointer"
>
<Archive className="h-4 w-4 mr-2" /> Arquivar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-[60vh]">
{/* Editor / Preview (3 cols) */}
<div className="lg:col-span-3 space-y-4">
{/* Desktop split */}
<div className="hidden lg:grid lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<Suspense fallback={<Skeleton className="h-60" />}>
<CodeMirrorEditor
value={markdown}
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
readOnly={!editing}
/>
</Suspense>
</div>
<div className="rounded-xl border border-border bg-card p-6 overflow-auto max-h-[80vh] prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
</div>
</div>
{/* Mobile tabs */}
<div className="lg:hidden">
<Tabs defaultValue="preview">
<TabsList className="w-full">
<TabsTrigger value="editor" className="flex-1">Editor</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">Preview</TabsTrigger>
</TabsList>
<TabsContent value="editor" className="mt-4">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<Suspense fallback={<Skeleton className="h-60" />}>
<CodeMirrorEditor
value={markdown}
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
readOnly={!editing}
/>
</Suspense>
</div>
</TabsContent>
<TabsContent value="preview" className="mt-4">
<div className="rounded-xl border border-border bg-card p-6 overflow-auto prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
</div>
</TabsContent>
</Tabs>
</div>
</div>
{/* Timeline sidebar (1 col) */}
<div className="lg:col-span-1">
<div className="rounded-xl border border-border bg-card p-4 space-y-3 lg:sticky lg:top-24">
<h3 className="font-semibold text-sm">Versões</h3>
{versions.data?.map((v) => (
<button
key={v.id}
onClick={() => selectVersion(v)}
className={`w-full text-left rounded-lg p-3 transition-colors text-sm ${
currentVersion?.id === v.id
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted border border-transparent"
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">v{v.version_number}</span>
{v.is_live && (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 text-[10px]">
Live
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(new Date(v.created_at), { addSuffix: true, locale: ptBR })}
</p>
{!v.is_live && currentVersion?.id !== v.id && (
<button
className="text-xs text-primary hover:underline mt-1"
onClick={(e) => {
e.stopPropagation();
publishVersion.mutate(v.id);
}}
>
Restaurar
</button>
)}
</button>
))}
</div>
</div>
</div>
{/* Test panel */}
{testOpen && currentVersion && (
<SkillTestPanel
open={testOpen}
onOpenChange={setTestOpen}
skillName={skill.data?.name ?? "Skill"}
skillVersionId={currentVersion.id}
triggerKeywords={skill.data?.trigger_keywords}
/>
)}
{/* Archive dialog */}
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle>
<AlertDialogDescription>
A skill ficará invisível para o agente. Você poderá restaurá-la depois.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={() => archiveSkill.mutate()}>Arquivar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
<Skeleton className="h-12 w-1/3" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-36 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Skills</h1>
<p className="mt-1 text-muted-foreground">
Crie automações personalizadas para seu agente Mika
</p>
</div>
{!noSub && (
<div className="flex items-center gap-3 flex-wrap">
{limits.data && limits.data.max_skills != null && (
<span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted">
{limits.data.current_skills_count} de {limits.data.max_skills} skills
</span>
)}
<div className="flex items-center gap-2">
<Switch id="archived" checked={showArchived} onCheckedChange={setShowArchived} />
<Label htmlFor="archived" className="text-sm text-muted-foreground cursor-pointer">
Arquivadas
</Label>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={atLimit || agentNotReady ? 0 : -1}>
<Button
asChild={!atLimit && !agentNotReady}
disabled={atLimit || agentNotReady}
className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
>
{atLimit || agentNotReady ? (
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nova skill
</span>
) : (
<Link to="/painel/skills/nova">
<Plus className="h-4 w-4 mr-1" /> Nova skill
</Link>
)}
</Button>
</span>
</TooltipTrigger>
{(atLimit || agentNotReady) && (
<TooltipContent>
{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.`}
</TooltipContent>
)}
</Tooltip>
</div>
)}
</header>
{noSub ? (
<NoSubscriptionState />
) : agentNotReady ? (
<AgentProvisioningState />
) : skills.data && skills.data.length === 0 ? (
<EmptySkillsState />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.data?.map((skill) => (
<SkillCard key={skill.id} skill={skill} />
))}
</div>
)}
</div>
);
}

View file

@ -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<SkillFormValues>({
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<string, unknown>,
});
} catch (e) {
toast.error("Erro inesperado ao gerar skill. Tente novamente.");
} finally {
setGenerating(false);
}
};
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to="/painel/skills">
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">Nova skill</h1>
<p className="text-sm text-muted-foreground">
Preencha os campos e gere o conteúdo com IA
</p>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form column */}
<div className="lg:col-span-2 space-y-5">
<FormField label="Nome" helperText="Nome curto para identificar a skill" error={form.formState.errors.name?.message}>
<Input {...form.register("name")} placeholder="Ex: Agendar reunião" maxLength={60} />
</FormField>
<FormField label="Descrição" helperText="O que essa skill faz, em uma frase" error={form.formState.errors.description?.message}>
<Textarea {...form.register("description")} placeholder="Ex: Cria eventos no Google Calendar a partir de instruções naturais" rows={2} maxLength={200} />
</FormField>
<FormField label="Gatilhos / palavras-chave" helperText="Quando o Mika deve acionar esta skill" error={form.formState.errors.trigger_keywords?.message}>
<Input {...form.register("trigger_keywords")} placeholder="Ex: agendar, marcar reunião, criar evento" maxLength={200} />
</FormField>
<FormField label="Inputs esperados" helperText="(Opcional) Que informações o usuário deve fornecer">
<Textarea {...form.register("expected_inputs")} placeholder="Ex: data, hora, participantes, assunto" rows={2} maxLength={500} />
</FormField>
<FormField label="Passo a passo" helperText="Descreva o que o Mika deve fazer (mín. 50 chars)" error={form.formState.errors.steps?.message}>
<Textarea
{...form.register("steps")}
placeholder="1. Extrair data e hora do input do usuário&#10;2. Verificar disponibilidade no Google Calendar&#10;3. Criar o evento com os participantes&#10;4. Enviar confirmação ao usuário"
rows={5}
maxLength={3000}
/>
</FormField>
<FormField label="Ferramentas necessárias" helperText="Quais serviços o Mika vai precisar" error={form.formState.errors.required_tools?.message}>
<Controller
control={form.control}
name="required_tools"
render={({ field }) => (
<div className="flex flex-wrap gap-2">
{AVAILABLE_TOOLS.map((tool) => {
const selected = field.value?.includes(tool);
return (
<Badge
key={tool}
variant={selected ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors select-none",
selected
? "bg-primary text-primary-foreground hover:bg-primary-dark"
: "hover:bg-muted",
)}
onClick={() => {
const next = selected
? field.value.filter((t) => t !== tool)
: [...(field.value ?? []), tool];
field.onChange(next);
}}
>
{tool}
</Badge>
);
})}
</div>
)}
/>
</FormField>
<FormField label="Critério de sucesso" helperText="Como saber se a skill foi executada corretamente" error={form.formState.errors.success_criteria?.message}>
<Input {...form.register("success_criteria")} placeholder="Ex: evento criado com data e participantes corretos" maxLength={300} />
</FormField>
<FormField label="Exemplo de uso" helperText="(Opcional) Uma situação concreta de uso">
<Textarea {...form.register("example_use_case")} placeholder='Ex: "Mika, agenda uma reunião com o João amanhã às 14h"' rows={2} maxLength={500} />
</FormField>
</div>
{/* Sticky checklist column */}
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-24 space-y-4">
<div className="rounded-xl border border-border bg-card p-5 shadow-soft">
<h3 className="font-semibold text-sm mb-4">Checklist</h3>
<ul className="space-y-2.5">
{FIELDS_META.map((f) => {
const done = fieldFilled(f.key);
return (
<li key={f.key} className="flex items-center gap-2 text-sm">
{done ? (
<Check className="h-4 w-4 text-emerald-500 shrink-0" />
) : (
<CircleDashed className="h-4 w-4 text-muted-foreground/50 shrink-0" />
)}
<span className={done ? "text-foreground" : "text-muted-foreground"}>
{f.label}
{!f.required && " (opcional)"}
</span>
</li>
);
})}
</ul>
</div>
<Button
onClick={handleGenerate}
disabled={!isValid || generating}
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground h-12 text-base"
>
{generating ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Gerando sua skill...
</>
) : (
<>
<Sparkles className="h-5 w-5 mr-2" />
Gerar com IA
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}
function FormField({
label,
helperText,
error,
children,
}: {
label: string;
helperText?: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label className="font-medium">{label}</Label>
{children}
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import { createFileRoute, useNavigate, Link, useRouter } from "@tanstack/react-router";
import { useState, lazy, Suspense, useCallback, useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Eye, FileText, Loader2, Play, Save } from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { useAgentInstance } from "@/hooks/use-agent-instance";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
// Lazy-load CodeMirror to reduce initial bundle
const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
export const Route = createFileRoute("/painel/skills/preview")({
component: SkillPreviewPage,
});
function SkillPreviewPage() {
const router = useRouter();
const navigate = useNavigate();
const { user } = useAuth();
const agent = useAgentInstance();
const qc = useQueryClient();
// State passed from /painel/skills/nova
const routerState = (router.state.location.state ?? {}) as {
markdown_content?: string;
form_inputs?: Record<string, unknown>;
};
const [markdown, setMarkdown] = useState(routerState.markdown_content ?? "");
const formInputs = useMemo(() => routerState.form_inputs ?? {}, [routerState.form_inputs]);
const [testOpen, setTestOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const agentId = agent.data?.id;
const hasContent = markdown.trim().length > 0;
// Helper: parse Supabase error codes
const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
if (error.code === "P0001") {
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
});
return true;
}
if (error.code === "P0002") {
toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
return true;
}
if (error.code === "23505") {
toast.error("Você já tem uma skill com esse nome. Escolha outro.");
return true;
}
return false;
}, [navigate]);
const createSkill = useCallback(async (publish: boolean) => {
if (!user || !agentId) return;
if (markdown.length > 50000) {
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
return;
}
const setter = publish ? setPublishing : setSaving;
setter(true);
try {
const fi = formInputs as Record<string, unknown>;
// 1. Create skill
const { data: skill, error: skillErr } = await supabase
.from("skills")
.insert({
user_id: user.id,
agent_instance_id: agentId,
name: (fi.name as string) || "Skill sem nome",
description: (fi.description as string) || "",
trigger_keywords: (fi.trigger_keywords as string) || "",
status: "draft",
})
.select("id")
.single();
if (skillErr) {
if (!handleSupabaseError(skillErr as { code?: string })) {
toast.error(skillErr.message || "Erro ao criar skill");
}
return;
}
// 2. Create version
const { data: ver, error: verErr } = await supabase
.from("skill_versions")
.insert({
skill_id: skill.id,
version_number: 1,
markdown_content: markdown,
form_inputs: formInputs as Record<string, unknown>,
is_live: false,
created_by: user.id,
})
.select("id")
.single();
if (verErr) {
toast.error(verErr.message || "Erro ao salvar versão");
return;
}
// 3. Optionally publish
if (publish) {
const { data: pubData, error: pubErr } = await supabase.functions.invoke(
"publish-skill-version",
{ body: { skill_version_id: ver.id } },
);
if (pubErr) {
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
} else {
toast.success("Skill publicada com sucesso!");
}
} else {
toast.success("Rascunho salvo!");
}
qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] });
navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
} catch {
toast.error("Erro inesperado");
} finally {
setter(false);
}
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
if (!routerState.markdown_content) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Nenhum conteúdo para pré-visualizar.</p>
<Button asChild variant="outline" className="mt-4">
<Link to="/painel/skills/nova">Voltar ao formulário</Link>
</Button>
</div>
);
}
return (
<div className="space-y-4">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to="/painel/skills/nova">
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<h1 className="text-xl font-bold">Pré-visualização</h1>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}>
<Play className="h-4 w-4 mr-1" /> Testar antes
</Button>
<Button variant="outline" onClick={() => createSkill(false)} disabled={saving || publishing || !hasContent}>
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
Salvar rascunho
</Button>
<Button
onClick={() => createSkill(true)}
disabled={saving || publishing || !hasContent}
className="bg-primary hover:bg-primary-dark text-primary-foreground"
>
{publishing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileText className="h-4 w-4 mr-1" />}
Publicar agora
</Button>
</div>
</header>
{/* Desktop: split | Mobile: tabs */}
<div className="hidden lg:grid lg:grid-cols-2 gap-4 min-h-[60vh]">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<Suspense fallback={<EditorSkeleton />}>
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
</Suspense>
</div>
<MarkdownPreview content={markdown} />
</div>
<div className="lg:hidden">
<Tabs defaultValue="preview">
<TabsList className="w-full">
<TabsTrigger value="editor" className="flex-1">
<FileText className="h-4 w-4 mr-1" /> Editor
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
<Eye className="h-4 w-4 mr-1" /> Preview
</TabsTrigger>
</TabsList>
<TabsContent value="editor" className="mt-4 min-h-[50vh]">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<Suspense fallback={<EditorSkeleton />}>
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
</Suspense>
</div>
</TabsContent>
<TabsContent value="preview" className="mt-4">
<MarkdownPreview content={markdown} />
</TabsContent>
</Tabs>
</div>
{testOpen && (
<SkillTestPanel
open={testOpen}
onOpenChange={setTestOpen}
skillName={(formInputs as Record<string, string>).name || "Skill"}
skillVersionId="preview"
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
stateless={{ markdown_content: markdown }}
/>
)}
</div>
);
}
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="rounded-xl border border-border bg-card p-6 overflow-auto max-h-[80vh] prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function EditorSkeleton() {
return (
<div className="flex items-center justify-center h-60">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}

View file

@ -0,0 +1,12 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import { Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/painel/skills")({
component: SkillsLayout,
});
function SkillsLayout() {
return <Outlet />;
}