Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 18:53:49 +00:00
parent 124f70528b
commit a32c05a679
10 changed files with 721 additions and 0 deletions

View file

@ -0,0 +1,18 @@
"use client";
import { Hourglass } from "lucide-react";
export function AgentProvisioningState() {
return (
<div className="rounded-xl border border-border bg-card p-8 sm:p-12 text-center shadow-soft">
<div className="mx-auto h-16 w-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<Hourglass className="h-8 w-8 text-amber-500 animate-pulse" />
</div>
<h2 className="mt-6 text-2xl font-bold">Aguardando seu agente ficar pronto</h2>
<p className="mt-2 text-muted-foreground max-w-md mx-auto">
Estamos provisionando sua instância do Mika. Você poderá criar skills assim
que o provisionamento terminar geralmente em até 10 minutos.
</p>
</div>
);
}

View file

@ -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 (
<div className="rounded-xl border border-dashed border-border bg-card/50 p-12 text-center">
<div className="mx-auto h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-10 w-10 text-primary/40" />
</div>
<h2 className="mt-6 text-xl font-bold">Você ainda não tem skills personalizadas</h2>
<p className="mt-2 text-muted-foreground max-w-md mx-auto">
Crie sua primeira skill em 2 minutos e ensine seu agente Mika a fazer
exatamente o que você precisa.
</p>
<Button
asChild
size="lg"
disabled={disabled}
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
>
<Link to="/painel/skills/nova">Criar primeira skill</Link>
</Button>
</div>
);
}

View file

@ -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 (
<div className="rounded-xl border border-border bg-card p-8 sm:p-12 text-center shadow-soft">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h2 className="mt-6 text-2xl font-bold">
Você precisa de uma assinatura ativa para criar skills
</h2>
<p className="mt-2 text-muted-foreground max-w-md mx-auto">
Escolha um plano para liberar o Skill Studio e começar a personalizar
seu agente Mika com automações próprias.
</p>
<Button
asChild
size="lg"
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
>
<Link to="/" hash="planos">
Ver planos
</Link>
</Button>
</div>
);
}

View file

@ -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 (
<>
<div className="group rounded-xl border border-border bg-card p-5 shadow-soft hover:shadow-glow transition-shadow">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<button
onClick={() => navigate({ to: "/painel/skills/$id", params: { id: skill.id } })}
className="text-left font-semibold text-base hover:text-primary transition-colors truncate w-full"
>
{skill.name}
</button>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 -mt-1 -mr-1 shrink-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{isArchived ? (
<>
<DropdownMenuItem onClick={handleRestore} className="cursor-pointer">
<RotateCcw className="h-4 w-4 mr-2" /> Restaurar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmDelete(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" /> Deletar
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/painel/skills/$id" params={{ id: skill.id }}>
<Edit className="h-4 w-4 mr-2" /> Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTestOpen(true)}
disabled={!skill.current_version_id}
className="cursor-pointer"
>
<Play className="h-4 w-4 mr-2" /> Testar
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleToggleActive}
disabled={!skill.current_version_id && skill.status !== "active"}
className="cursor-pointer"
>
<Power className="h-4 w-4 mr-2" />
{skill.status === "active" ? "Desativar" : "Ativar"}
</DropdownMenuItem>
<DropdownMenuItem disabled className="cursor-not-allowed opacity-50">
<Copy className="h-4 w-4 mr-2" /> Duplicar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmArchive(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Archive className="h-4 w-4 mr-2" /> Arquivar
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<SkillStatusBadge status={skill.status} />
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
</span>
</div>
</div>
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle>
<AlertDialogDescription>
A skill <strong>{skill.name}</strong> ficará invisível para o agente. Você
poderá restaurá-la depois sem perder o histórico.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>Arquivar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deletar definitivamente?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões.
Não é possível desfazer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Deletar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{testOpen && skill.current_version_id && (
<SkillTestPanel
open={testOpen}
onOpenChange={setTestOpen}
skillName={skill.name}
skillVersionId={skill.current_version_id}
triggerKeywords={skill.trigger_keywords}
/>
)}
</>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const STATUS_LABELS: Record<string, string> = {
draft: "Rascunho",
testing: "Em teste",
active: "Ativa",
disabled: "Desativada",
archived: "Arquivada",
};
const STATUS_CLASSES: Record<string, string> = {
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 (
<Badge
variant="outline"
className={cn(
"rounded-md font-medium",
STATUS_CLASSES[status] ?? STATUS_CLASSES.draft,
className,
)}
>
{STATUS_LABELS[status] ?? status}
</Badge>
);
}

View file

@ -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<TestResult | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(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<TestResult> => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Play className="h-5 w-5 text-primary" />
Testar skill: <span className="text-primary">{skillName}</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-2">
Digite um exemplo de input
</label>
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={placeholder}
rows={3}
className="resize-none"
disabled={runTest.isPending}
/>
</div>
<Button
onClick={() => runTest.mutate()}
disabled={!input.trim() || runTest.isPending}
className="w-full bg-primary hover:bg-primary-dark text-primary-foreground"
>
{runTest.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Mika está pensando...
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Executar teste
</>
)}
</Button>
{result && (
<div
className={
result.status === "success"
? "rounded-xl border border-blue-500/30 bg-blue-500/5 p-4"
: "rounded-xl border border-destructive/30 bg-destructive/5 p-4"
}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-sm">
{result.status === "success" ? "Resultado do teste" : "Erro no teste"}
</h3>
{result.status === "success" && (
<Badge variant="outline" className="bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30">
Dry-run
</Badge>
)}
<button onClick={() => setResult(null)} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="text-sm whitespace-pre-wrap">
{result.status === "success" ? result.test_output : result.error_message}
</div>
{result.duration_ms > 0 && (
<p className="mt-3 text-xs text-muted-foreground">
Executado em {(result.duration_ms / 1000).toFixed(1)}s
</p>
)}
</div>
)}
{!stateless && history.data && history.data.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Histórico ({history.data.length})
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-2">
{history.data.map((run) => (
<div key={run.id} className="rounded-lg border border-border bg-muted/30 p-3 text-xs">
<div className="flex items-center justify-between mb-1">
<Badge
variant="outline"
className={
run.status === "success"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
: "bg-destructive/15 text-destructive border-destructive/30"
}
>
{run.status === "success" ? "Sucesso" : "Erro"}
</Badge>
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(run.created_at), { addSuffix: true, locale: ptBR })}
</span>
</div>
<p className="font-medium text-foreground/80 truncate">{run.test_input}</p>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
<p className="text-xs text-muted-foreground border-t border-border pt-3">
O teste em modo simulação não executa ferramentas reais. Em breve você poderá
testar a skill diretamente no seu agente.
</p>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
export interface AgentInstance {
id: string;
user_id: string;
status: string;
telegram_bot_username: string | null;
created_at: string;
}
export function useAgentInstance() {
const { user } = useAuth();
return useQuery({
queryKey: ["agent-instance", user?.id],
enabled: !!user,
queryFn: async (): Promise<AgentInstance | null> => {
const { data, error } = await supabase
.from("agent_instances")
.select("id, user_id, status, telegram_bot_username, created_at")
.eq("user_id", user!.id)
.maybeSingle();
if (error) throw error;
return data;
},
});
}

46
src/hooks/use-skills.ts Normal file
View file

@ -0,0 +1,46 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import type { Database } from "@/integrations/supabase/types";
export type Skill = Database["public"]["Tables"]["skills"]["Row"];
export type SkillStatus = "draft" | "testing" | "active" | "disabled" | "archived";
export function useSkills(includeArchived = false) {
const { user } = useAuth();
return useQuery({
queryKey: ["skills", user?.id, includeArchived],
enabled: !!user,
queryFn: async (): Promise<Skill[]> => {
let q = supabase
.from("skills")
.select("*")
.eq("user_id", user!.id)
.order("updated_at", { ascending: false });
if (!includeArchived) {
q = q.neq("status", "archived");
}
const { data, error } = await q;
if (error) throw error;
return (data ?? []) as Skill[];
},
});
}
export function useSkill(skillId: string | undefined) {
return useQuery({
queryKey: ["skill", skillId],
enabled: !!skillId,
queryFn: async (): Promise<Skill | null> => {
const { data, error } = await supabase
.from("skills")
.select("*")
.eq("id", skillId!)
.maybeSingle();
if (error) throw error;
return data as Skill | null;
},
});
}

View file

@ -0,0 +1,29 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
export interface UserSkillLimits {
user_id: string;
plan_slug: string | null;
max_skills: number | null;
current_skills_count: number;
}
export function useUserSkillLimits() {
const { user } = useAuth();
return useQuery({
queryKey: ["user-limits", user?.id],
enabled: !!user,
queryFn: async (): Promise<UserSkillLimits | null> => {
const { data, error } = await supabase
.from("user_skill_limits")
.select("*")
.eq("user_id", user!.id)
.maybeSingle();
if (error) throw error;
return data as UserSkillLimits | null;
},
});
}

47
src/lib/skill-schema.ts Normal file
View file

@ -0,0 +1,47 @@
import { z } from "zod";
export const skillFormSchema = z.object({
name: z
.string()
.trim()
.min(2, "Mínimo 2 caracteres")
.max(60, "Máximo 60 caracteres"),
description: z
.string()
.trim()
.min(10, "Descreva em pelo menos 10 caracteres")
.max(200, "Máximo 200 caracteres"),
trigger_keywords: z
.string()
.trim()
.min(2, "Adicione ao menos 1 palavra-chave")
.max(200, "Máximo 200 caracteres"),
expected_inputs: z.string().trim().max(500).nullable().optional(),
steps: z
.string()
.trim()
.min(50, "Descreva o passo a passo (mín. 50 caracteres)")
.max(3000, "Máximo 3000 caracteres"),
required_tools: z
.array(z.string())
.min(1, "Selecione pelo menos uma ferramenta"),
success_criteria: z
.string()
.trim()
.min(5, "Defina o critério de sucesso")
.max(300, "Máximo 300 caracteres"),
example_use_case: z.string().trim().max(500).nullable().optional(),
});
export type SkillFormValues = z.infer<typeof skillFormSchema>;
export const AVAILABLE_TOOLS = [
"Gmail",
"Google Calendar",
"Google Drive",
"Notion",
"Cal.com",
"Microsoft Outlook",
"Todoist",
"Web Search",
] as const;