fix: harden skill defaults and dry-run preview (#4)

This commit is contained in:
Felipe Domingues 2026-05-28 18:53:40 -03:00 committed by GitHub
parent 25b2fa5af2
commit b2b9a24460
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 495 additions and 254 deletions

View file

@ -33,7 +33,9 @@ export function SkillsDashboardWidget() {
<h3 className="font-semibold">Suas skills</h3> <h3 className="font-semibold">Suas skills</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{activeCount} ativa{activeCount === 1 ? "" : "s"} {activeCount} ativa{activeCount === 1 ? "" : "s"}
{limits?.max_skills ? ` de ${limits.max_skills} disponíveis` : ""} {limits?.max_skills
? ` · ${limits.current_skills_count} de ${limits.max_skills} personalizadas`
: ""}
</p> </p>
</div> </div>
</div> </div>
@ -67,7 +69,11 @@ export function SkillsDashboardWidget() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{skill.name}</p> <p className="text-sm font-medium truncate">{skill.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Atualizada {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })} Atualizada{" "}
{formatDistanceToNow(new Date(skill.updated_at), {
addSuffix: true,
locale: ptBR,
})}
</p> </p>
</div> </div>
<SkillStatusBadge status={skill.status} className="shrink-0" /> <SkillStatusBadge status={skill.status} className="shrink-0" />

View file

@ -10,6 +10,7 @@ import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { syncAgentSkills } from "@/lib/sync-agent-skills"; import { syncAgentSkills } from "@/lib/sync-agent-skills";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -128,9 +129,7 @@ export function SkillCard({ skill }: { skill: Skill }) {
> >
{skill.name} {skill.name}
</button> </button>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2"> <p className="mt-1 text-sm text-muted-foreground line-clamp-2">{skill.description}</p>
{skill.description}
</p>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -191,7 +190,14 @@ export function SkillCard({ skill }: { skill: Skill }) {
</div> </div>
<div className="mt-4 flex items-center justify-between gap-2"> <div className="mt-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<SkillStatusBadge status={skill.status} /> <SkillStatusBadge status={skill.status} />
{skill.is_default && (
<Badge variant="muted" className="rounded-md font-medium">
Padrão
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })} {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
</span> </span>
@ -203,8 +209,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle> <AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
A skill <strong>{skill.name}</strong> ficará invisível para o agente. Você A skill <strong>{skill.name}</strong> ficará invisível para o agente. Você poderá
poderá restaurá-la depois sem perder o histórico. restaurá-la depois sem perder o histórico.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -219,8 +225,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Deletar definitivamente?</AlertDialogTitle> <AlertDialogTitle>Deletar definitivamente?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões. Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões. Não é
Não é possível desfazer. possível desfazer.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View file

@ -6,20 +6,11 @@ import { Loader2, Play, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface Props { interface Props {
open: boolean; open: boolean;
@ -80,7 +71,7 @@ export function SkillTestPanel({
// Modo preview sem persistência: chama AI direto via edge function // Modo preview sem persistência: chama AI direto via edge function
const start = Date.now(); const start = Date.now();
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", { const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
body: { skill_version_id: skillVersionId, test_input: input }, body: { markdown_content: stateless.markdown_content, test_input: input },
}); });
if (error) { if (error) {
return { return {
@ -122,9 +113,7 @@ export function SkillTestPanel({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm font-medium block mb-2"> <label className="text-sm font-medium block mb-2">Digite um exemplo de input</label>
Digite um exemplo de input
</label>
<Textarea <Textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
@ -166,12 +155,11 @@ export function SkillTestPanel({
<h3 className="font-semibold text-sm"> <h3 className="font-semibold text-sm">
{result.status === "success" ? "Resultado do teste" : "Erro no teste"} {result.status === "success" ? "Resultado do teste" : "Erro no teste"}
</h3> </h3>
{result.status === "success" && ( {result.status === "success" && <Badge variant="info">Dry-run</Badge>}
<Badge variant="info"> <button
Dry-run onClick={() => setResult(null)}
</Badge> className="ml-auto text-muted-foreground hover:text-foreground"
)} >
<button onClick={() => setResult(null)} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
@ -193,7 +181,10 @@ export function SkillTestPanel({
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-2"> <CollapsibleContent className="mt-3 space-y-2">
{history.data.map((run) => ( {history.data.map((run) => (
<div key={run.id} className="rounded-lg border border-border bg-muted/30 p-3 text-xs"> <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"> <div className="flex items-center justify-between mb-1">
<Badge <Badge
variant="outline" variant="outline"
@ -206,7 +197,10 @@ export function SkillTestPanel({
{run.status === "success" ? "Sucesso" : "Erro"} {run.status === "success" ? "Sucesso" : "Erro"}
</Badge> </Badge>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{formatDistanceToNow(new Date(run.created_at), { addSuffix: true, locale: ptBR })} {formatDistanceToNow(new Date(run.created_at), {
addSuffix: true,
locale: ptBR,
})}
</span> </span>
</div> </div>
<p className="font-medium text-foreground/80 truncate">{run.test_input}</p> <p className="font-medium text-foreground/80 truncate">{run.test_input}</p>
@ -217,8 +211,8 @@ export function SkillTestPanel({
)} )}
<p className="text-xs text-muted-foreground border-t border-border pt-3"> <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á O teste em modo simulação não executa ferramentas reais. Em breve você poderá testar a
testar a skill diretamente no seu agente. skill diretamente no seu agente.
</p> </p>
</div> </div>
</DialogContent> </DialogContent>

View file

@ -694,6 +694,7 @@ export type Database = {
current_version_id: string | null current_version_id: string | null
description: string description: string
id: string id: string
is_default: boolean
name: string name: string
status: string status: string
trigger_keywords: string trigger_keywords: string
@ -706,6 +707,7 @@ export type Database = {
current_version_id?: string | null current_version_id?: string | null
description: string description: string
id?: string id?: string
is_default?: boolean
name: string name: string
status?: string status?: string
trigger_keywords: string trigger_keywords: string
@ -718,6 +720,7 @@ export type Database = {
current_version_id?: string | null current_version_id?: string | null
description?: string description?: string
id?: string id?: string
is_default?: boolean
name?: string name?: string
status?: string status?: string
trigger_keywords?: string trigger_keywords?: string

View file

@ -19,7 +19,10 @@ export const Route = createFileRoute("/painel/agente")({
component: AgentePage, component: AgentePage,
}); });
const STATUS_MAP: Record<string, { label: string; variant: "warning" | "success" | "destructive"; pulse?: boolean }> = { const STATUS_MAP: Record<
string,
{ label: string; variant: "warning" | "success" | "destructive"; pulse?: boolean }
> = {
provisioning: { label: "Provisionando", variant: "warning", pulse: true }, provisioning: { label: "Provisionando", variant: "warning", pulse: true },
active: { label: "Online", variant: "success" }, active: { label: "Online", variant: "success" },
suspended: { label: "Suspenso", variant: "destructive" }, suspended: { label: "Suspenso", variant: "destructive" },
@ -68,20 +71,22 @@ function AgentePage() {
<div className="space-y-6"> <div className="space-y-6">
<header> <header>
<h1 className="text-3xl font-bold tracking-tight">Meu Agente</h1> <h1 className="text-3xl font-bold tracking-tight">Meu Agente</h1>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">Gerencie seu agente pessoal de IA</p>
Gerencie seu agente pessoal de IA
</p>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Card 1: Seu agente — full width */} {/* 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="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className={cn( <div
className={cn(
"h-14 w-14 rounded-full flex items-center justify-center shrink-0", "h-14 w-14 rounded-full flex items-center justify-center shrink-0",
status === "active" ? "bg-success/10" : "bg-primary/10", status === "active" ? "bg-success/10" : "bg-primary/10",
)}> )}
<Bot className={cn("h-7 w-7", status === "active" ? "text-success" : "text-primary")} /> >
<Bot
className={cn("h-7 w-7", status === "active" ? "text-success" : "text-primary")}
/>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@ -128,14 +133,17 @@ function AgentePage() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatItem label="Interações hoje" value="0" /> <StatItem label="Interações hoje" value="0" />
<StatItem <StatItem
label="Skills ativas" label="Skills personalizadas"
value={limits.isLoading ? "..." : String(limits.data?.current_skills_count ?? 0)} value={limits.isLoading ? "..." : String(limits.data?.current_skills_count ?? 0)}
/> />
<StatItem <StatItem
label="Último teste" label="Último teste"
value={ value={
lastTestRun.data lastTestRun.data
? formatDistanceToNow(new Date(lastTestRun.data), { addSuffix: true, locale: ptBR }) ? formatDistanceToNow(new Date(lastTestRun.data), {
addSuffix: true,
locale: ptBR,
})
: "—" : "—"
} }
/> />

View file

@ -3,7 +3,18 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"; import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useState, useCallback, lazy, Suspense } from "react"; import { useState, useCallback, lazy, Suspense } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react"; import {
ArrowLeft,
Check,
Loader2,
Play,
Rocket,
Save,
MoreVertical,
Copy,
Archive,
Trash2,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
@ -81,23 +92,20 @@ function SkillDetailPage() {
const [confirmArchive, setConfirmArchive] = useState(false); const [confirmArchive, setConfirmArchive] = useState(false);
// Sync markdown when versions load or selection changes // Sync markdown when versions load or selection changes
const currentVersion = versions.data?.find((v) => const currentVersion =
selectedVersionId ? v.id === selectedVersionId : v.is_live, versions.data?.find((v) => (selectedVersionId ? v.id === selectedVersionId : v.is_live)) ??
) ?? versions.data?.[0]; versions.data?.[0];
if (currentVersion && markdown === "" && !editing) { if (currentVersion && markdown === "" && !editing) {
// initial load // initial load
setTimeout(() => setMarkdown(currentVersion.markdown_content), 0); setTimeout(() => setMarkdown(currentVersion.markdown_content), 0);
} }
const selectVersion = useCallback( const selectVersion = useCallback((v: SkillVersion) => {
(v: SkillVersion) => {
setSelectedVersionId(v.id); setSelectedVersionId(v.id);
setMarkdown(v.markdown_content); setMarkdown(v.markdown_content);
setEditing(false); setEditing(false);
}, }, []);
[],
);
// Save new version // Save new version
const saveVersion = useMutation({ const saveVersion = useMutation({
@ -106,7 +114,8 @@ function SkillDetailPage() {
const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0); const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
const { data, error } = await supabase const { data, error } = await supabase
.from("skill_versions") .from("skill_versions")
.insert([{ .insert([
{
skill_id: id, skill_id: id,
version_number: maxVer + 1, version_number: maxVer + 1,
markdown_content: markdown, markdown_content: markdown,
@ -114,7 +123,8 @@ function SkillDetailPage() {
form_inputs: (currentVersion?.form_inputs ?? {}) as any, form_inputs: (currentVersion?.form_inputs ?? {}) as any,
is_live: false, is_live: false,
created_by: user.id, created_by: user.id,
}]) },
])
.select("id, version_number") .select("id, version_number")
.single(); .single();
if (error) throw error; if (error) throw error;
@ -234,6 +244,11 @@ function SkillDetailPage() {
<h1 className="text-xl font-bold truncate">{skill.data.name}</h1> <h1 className="text-xl font-bold truncate">{skill.data.name}</h1>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<SkillStatusBadge status={skill.data.status} /> <SkillStatusBadge status={skill.data.status} />
{skill.data.is_default && (
<Badge variant="muted" className="rounded-md font-medium">
Padrão
</Badge>
)}
{currentVersion && ( {currentVersion && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
v{currentVersion.version_number} v{currentVersion.version_number}
@ -323,7 +338,10 @@ function SkillDetailPage() {
<Suspense fallback={<Skeleton className="h-60" />}> <Suspense fallback={<Skeleton className="h-60" />}>
<CodeMirrorEditor <CodeMirrorEditor
value={markdown} value={markdown}
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }} onChange={(v) => {
setMarkdown(v);
if (!editing) setEditing(true);
}}
readOnly={!editing} readOnly={!editing}
/> />
</Suspense> </Suspense>
@ -336,15 +354,22 @@ function SkillDetailPage() {
<div className="lg:hidden"> <div className="lg:hidden">
<Tabs defaultValue="preview"> <Tabs defaultValue="preview">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="editor" className="flex-1">Editor</TabsTrigger> <TabsTrigger value="editor" className="flex-1">
<TabsTrigger value="preview" className="flex-1">Preview</TabsTrigger> Editor
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Preview
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="editor" className="mt-4"> <TabsContent value="editor" className="mt-4">
<div className="rounded-xl border border-border bg-card overflow-hidden"> <div className="rounded-xl border border-border bg-card overflow-hidden">
<Suspense fallback={<Skeleton className="h-60" />}> <Suspense fallback={<Skeleton className="h-60" />}>
<CodeMirrorEditor <CodeMirrorEditor
value={markdown} value={markdown}
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }} onChange={(v) => {
setMarkdown(v);
if (!editing) setEditing(true);
}}
readOnly={!editing} readOnly={!editing}
/> />
</Suspense> </Suspense>

View file

@ -14,11 +14,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const Route = createFileRoute("/painel/skills/")({ export const Route = createFileRoute("/painel/skills/")({
component: SkillsPage, component: SkillsPage,
@ -31,10 +27,8 @@ function SkillsPage() {
const skills = useSkills(showArchived); const skills = useSkills(showArchived);
const loading = limits.isLoading || agent.isLoading || skills.isLoading; const loading = limits.isLoading || agent.isLoading || skills.isLoading;
const noSub = !limits.isLoading && (limits.data?.max_skills == null); const noSub = !limits.isLoading && limits.data?.max_skills == null;
const agentNotReady = const agentNotReady = !agent.isLoading && (!agent.data || agent.data.status === "provisioning");
!agent.isLoading &&
(!agent.data || agent.data.status === "provisioning");
const atLimit = const atLimit =
limits.data != null && limits.data != null &&
limits.data.max_skills != null && limits.data.max_skills != null &&
@ -67,7 +61,7 @@ function SkillsPage() {
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{limits.data && limits.data.max_skills != null && ( {limits.data && limits.data.max_skills != null && (
<span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted"> <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 {limits.data.current_skills_count} de {limits.data.max_skills} personalizadas
</span> </span>
)} )}
@ -102,7 +96,7 @@ function SkillsPage() {
<TooltipContent> <TooltipContent>
{agentNotReady {agentNotReady
? "Aguarde o provisionamento do agente terminar" ? "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.`} : `Limite de ${limits.data?.max_skills} skills personalizadas do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>

View file

@ -45,7 +45,8 @@ function SkillPreviewPage() {
const hasContent = markdown.trim().length > 0; const hasContent = markdown.trim().length > 0;
// Helper: parse Supabase error codes // Helper: parse Supabase error codes
const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => { const handleSupabaseError = useCallback(
(error: { code?: string; message?: string }) => {
if (error.code === "P0001") { if (error.code === "P0001") {
toast.error("Você precisa de uma assinatura ativa para criar skills.", { toast.error("Você precisa de uma assinatura ativa para criar skills.", {
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) }, action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
@ -53,7 +54,9 @@ function SkillPreviewPage() {
return true; return true;
} }
if (error.code === "P0002") { if (error.code === "P0002") {
toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente."); toast.error(
"Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.",
);
return true; return true;
} }
if (error.code === "23505") { if (error.code === "23505") {
@ -61,9 +64,12 @@ function SkillPreviewPage() {
return true; return true;
} }
return false; return false;
}, [navigate]); },
[navigate],
);
const createSkill = useCallback(async (publish: boolean) => { const createSkill = useCallback(
async (publish: boolean) => {
if (!user || !agentId) return; if (!user || !agentId) return;
if (markdown.length > 50000) { if (markdown.length > 50000) {
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar."); toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
@ -99,7 +105,8 @@ function SkillPreviewPage() {
// 2. Create version // 2. Create version
const { data: ver, error: verErr } = await supabase const { data: ver, error: verErr } = await supabase
.from("skill_versions") .from("skill_versions")
.insert([{ .insert([
{
skill_id: skill.id, skill_id: skill.id,
version_number: 1, version_number: 1,
markdown_content: markdown, markdown_content: markdown,
@ -107,11 +114,13 @@ function SkillPreviewPage() {
form_inputs: formInputs as any, form_inputs: formInputs as any,
is_live: false, is_live: false,
created_by: user.id, created_by: user.id,
}]) },
])
.select("id") .select("id")
.single(); .single();
if (verErr) { if (verErr) {
await supabase.from("skills").delete().eq("id", skill.id);
toast.error(verErr.message || "Erro ao salvar versão"); toast.error(verErr.message || "Erro ao salvar versão");
return; return;
} }
@ -122,10 +131,7 @@ function SkillPreviewPage() {
success?: boolean; success?: boolean;
synced?: boolean; synced?: boolean;
sync_error?: string; sync_error?: string;
}>( }>("publish-skill-version", { body: { skill_version_id: ver.id } });
"publish-skill-version",
{ body: { skill_version_id: ver.id } },
);
if (pubErr) { if (pubErr) {
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message); toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
} else if (pubData?.synced === false) { } else if (pubData?.synced === false) {
@ -147,7 +153,9 @@ function SkillPreviewPage() {
} finally { } finally {
setter(false); setter(false);
} }
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]); },
[user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc],
);
if (!routerState.markdown_content) { if (!routerState.markdown_content) {
return ( return (
@ -175,8 +183,16 @@ function SkillPreviewPage() {
<Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}> <Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}>
<Play className="h-4 w-4 mr-1" /> Testar antes <Play className="h-4 w-4 mr-1" /> Testar antes
</Button> </Button>
<Button variant="outline" onClick={() => createSkill(false)} disabled={saving || publishing || !hasContent}> <Button
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />} 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 Salvar rascunho
</Button> </Button>
<Button <Button
@ -184,7 +200,11 @@ function SkillPreviewPage() {
disabled={saving || publishing || !hasContent} disabled={saving || publishing || !hasContent}
className="bg-primary hover:bg-primary-dark text-primary-foreground" 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" />} {publishing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
Publicar agora Publicar agora
</Button> </Button>
</div> </div>

View file

@ -220,11 +220,16 @@ export async function ensureDefaultSkillsForAgent(
for (const template of DEFAULT_HERMES_SKILLS) { for (const template of DEFAULT_HERMES_SKILLS) {
const { data: existing, error: existingErr } = await supabase const { data: existing, error: existingErr } = await supabase
.from("skills") .from("skills")
.select("id") .select("id, current_version_id, is_default")
.eq("user_id", agent.user_id) .eq("user_id", agent.user_id)
.eq("name", template.name) .eq("name", template.name)
.neq("status", "archived") .neq("status", "archived")
.maybeSingle(); .maybeSingle();
const existingSkill = existing as {
id: string;
current_version_id: string | null;
is_default?: boolean | null;
} | null;
if (existingErr) { if (existingErr) {
result.errors.push( result.errors.push(
@ -233,11 +238,20 @@ export async function ensureDefaultSkillsForAgent(
continue; continue;
} }
if (existing) { if (existingSkill?.current_version_id) {
result.skipped_count += 1; result.skipped_count += 1;
continue; continue;
} }
if (existingSkill) {
if (existingSkill.is_default) {
await supabase.from("skills").delete().eq("id", existingSkill.id);
} else {
result.skipped_count += 1;
continue;
}
}
const { data: skillData, error: skillErr } = await supabase const { data: skillData, error: skillErr } = await supabase
.from("skills") .from("skills")
.insert({ .insert({
@ -246,6 +260,7 @@ export async function ensureDefaultSkillsForAgent(
name: template.name, name: template.name,
description: template.description, description: template.description,
trigger_keywords: template.trigger_keywords, trigger_keywords: template.trigger_keywords,
is_default: true,
status: "draft", status: "draft",
}) })
.select("id") .select("id")
@ -274,6 +289,7 @@ export async function ensureDefaultSkillsForAgent(
const version = versionData as { id: string } | null; const version = versionData as { id: string } | null;
if (versionErr || !version) { if (versionErr || !version) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push( result.errors.push(
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`, `${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
); );
@ -290,6 +306,7 @@ export async function ensureDefaultSkillsForAgent(
.eq("id", skill.id); .eq("id", skill.id);
if (updateErr) { if (updateErr) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`); result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
continue; continue;
} }

View file

@ -11,6 +11,57 @@ const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de te
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
class DryRunError extends Error {
status: number;
clientMessage: string;
constructor(status: number, clientMessage: string, logMessage = clientMessage) {
super(logMessage);
this.status = status;
this.clientMessage = clientMessage;
}
}
async function generateDryRunOutput(markdownContent: string, testInput: string): Promise<string> {
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: `Definição da skill:\n\`\`\`\n${markdownContent}\n\`\`\`\n\nInput do usuário: ${testInput}`,
},
],
}),
});
if (aiRes.status === 429) {
throw new DryRunError(
429,
"Muitas requisições. Aguarde 1 minuto e tente novamente.",
"Muitas requisições. Aguarde 1 minuto.",
);
}
if (!aiRes.ok) {
const txt = await aiRes.text();
throw new DryRunError(
500,
"Falha ao executar teste.",
`AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
);
}
const data = await aiRes.json();
return (data?.choices?.[0]?.message?.content ?? "").trim();
}
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); return new Response(null, { headers: corsHeaders });
@ -34,7 +85,7 @@ Deno.serve(async (req) => {
} }
const userId = userData.user.id; const userId = userData.user.id;
let body: { skill_version_id?: string; test_input?: string }; let body: { skill_version_id?: string; markdown_content?: string; test_input?: string };
try { try {
body = await req.json(); body = await req.json();
} catch { } catch {
@ -44,19 +95,57 @@ Deno.serve(async (req) => {
}); });
} }
const { skill_version_id, test_input } = body; const skillVersionId =
if (!skill_version_id || !test_input || test_input.trim().length === 0) { typeof body.skill_version_id === "string" ? body.skill_version_id.trim() : "";
const markdownContent =
typeof body.markdown_content === "string" ? body.markdown_content.trim() : "";
const testInput = typeof body.test_input === "string" ? body.test_input.trim() : "";
if (testInput.length === 0) {
return new Response(JSON.stringify({ error: "test_input é obrigatório" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (!skillVersionId && !markdownContent) {
return new Response( return new Response(
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }), JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
); );
} }
if (markdownContent.length > 50000) {
return new Response(JSON.stringify({ error: "markdown_content excede 50000 caracteres" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (markdownContent) {
const startedAt = Date.now();
try {
const test_output = await generateDryRunOutput(markdownContent, testInput);
return new Response(
JSON.stringify({ test_output, duration_ms: Date.now() - startedAt, status: "success" }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e) {
const msg = e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.";
const status = e instanceof DryRunError ? e.status : 500;
console.error("Stateless skill dry-run failed:", e instanceof Error ? e.message : String(e));
return new Response(JSON.stringify({ error: msg }), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
}
// Verifica ownership: skill_version -> skill -> user_id // Verifica ownership: skill_version -> skill -> user_id
const { data: versionRow, error: vErr } = await admin const { data: versionRow, error: vErr } = await admin
.from("skill_versions") .from("skill_versions")
.select("id, markdown_content, skills!inner(user_id)") .select("id, markdown_content, skills!inner(user_id)")
.eq("id", skill_version_id) .eq("id", skillVersionId)
.maybeSingle(); .maybeSingle();
if (vErr || !versionRow) { if (vErr || !versionRow) {
@ -77,9 +166,9 @@ Deno.serve(async (req) => {
const { data: runRow, error: runErr } = await admin const { data: runRow, error: runErr } = await admin
.from("skill_test_runs") .from("skill_test_runs")
.insert({ .insert({
skill_version_id, skill_version_id: skillVersionId,
user_id: userId, user_id: userId,
test_input, test_input: testInput,
status: "running", status: "running",
test_type: "dry_run", test_type: "dry_run",
}) })
@ -98,59 +187,7 @@ Deno.serve(async (req) => {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { const test_output = await generateDryRunOutput(versionRow.markdown_content, testInput);
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`,
},
],
}),
});
if (aiRes.status === 429) {
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
status: "error",
error_message: "Muitas requisições. Aguarde 1 minuto.",
duration_ms: duration,
})
.eq("id", runId);
return new Response(
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (!aiRes.ok) {
const txt = await aiRes.text();
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
status: "error",
error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
duration_ms: duration,
})
.eq("id", runId);
return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const data = await aiRes.json();
const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await admin await admin
@ -158,10 +195,10 @@ Deno.serve(async (req) => {
.update({ status: "success", test_output, duration_ms: duration }) .update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId); .eq("id", runId);
return new Response( return new Response(JSON.stringify({ test_output, duration_ms: duration, status: "success" }), {
JSON.stringify({ test_output, duration_ms: duration, status: "success" }), status: 200,
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, headers: { ...corsHeaders, "Content-Type": "application/json" },
); });
} catch (e) { } catch (e) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido"; const msg = e instanceof Error ? e.message : "Erro desconhecido";
@ -169,9 +206,14 @@ Deno.serve(async (req) => {
.from("skill_test_runs") .from("skill_test_runs")
.update({ status: "error", error_message: msg, duration_ms: duration }) .update({ status: "error", error_message: msg, duration_ms: duration })
.eq("id", runId); .eq("id", runId);
return new Response(JSON.stringify({ error: "Erro ao executar teste." }), { return new Response(
status: 500, JSON.stringify({
error: e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.",
}),
{
status: e instanceof DryRunError ? e.status : 500,
headers: { ...corsHeaders, "Content-Type": "application/json" }, headers: { ...corsHeaders, "Content-Type": "application/json" },
}); },
);
} }
}); });

View file

@ -0,0 +1,126 @@
-- Mark platform-seeded Hermes skills separately from user-created skills.
-- Default skills stay active and synced to the runtime, but do not consume the
-- customer's custom skill quota.
ALTER TABLE public.skills
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN public.skills.is_default IS
'True for Mika/Hermes skills seeded by the platform. These do not count against the custom skill quota.';
UPDATE public.skills
SET is_default = true
WHERE is_default = false
AND (
(
name = 'Resumo diario'
AND description = 'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.'
)
OR (
name = 'Planejamento semanal'
AND description = 'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.'
)
OR (
name = 'Preparar reuniao'
AND description = 'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
)
);
CREATE OR REPLACE VIEW public.user_skill_limits
WITH (security_invoker = true)
AS
SELECT
p.id AS user_id,
pl.slug AS plan_slug,
CASE
WHEN pl.slug IS NULL THEN NULL
WHEN pl.slug = 'basic' THEN 5
WHEN pl.slug = 'starter' THEN 15
WHEN pl.slug = 'professional' THEN 50
WHEN pl.slug = 'enterprise' THEN 999999
ELSE NULL
END AS max_skills,
(
SELECT count(*)::int
FROM public.skills sk
WHERE sk.user_id = p.id
AND sk.status != 'archived'
AND COALESCE(sk.is_default, false) = false
) AS current_skills_count
FROM public.profiles p
LEFT JOIN public.subscriptions s
ON s.user_id = p.id
AND s.status IN ('active', 'trialing')
LEFT JOIN public.plans pl
ON pl.id = s.plan_id;
GRANT SELECT ON public.user_skill_limits TO authenticated;
CREATE OR REPLACE FUNCTION public.protect_skill_default_flag()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
BEGIN
IF v_request_role = 'service_role' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' THEN
NEW.is_default := OLD.is_default;
ELSE
NEW.is_default := false;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trigger_protect_skill_default_flag ON public.skills;
CREATE TRIGGER trigger_protect_skill_default_flag
BEFORE INSERT OR UPDATE ON public.skills
FOR EACH ROW EXECUTE FUNCTION public.protect_skill_default_flag();
CREATE OR REPLACE FUNCTION public.enforce_skill_limit()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_max int;
v_count int;
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
BEGIN
IF COALESCE(NEW.is_default, false) THEN
IF v_request_role = 'service_role' THEN
RETURN NEW;
END IF;
NEW.is_default := false;
END IF;
SELECT max_skills, current_skills_count
INTO v_max, v_count
FROM public.user_skill_limits
WHERE user_id = NEW.user_id;
IF v_max IS NULL THEN
RAISE EXCEPTION 'Sem assinatura ativa para criar skills'
USING ERRCODE = 'P0001';
END IF;
IF v_count >= v_max THEN
RAISE EXCEPTION 'Limite de skills atingido para o plano'
USING ERRCODE = 'P0002';
END IF;
RETURN NEW;
END;
$$;
REVOKE EXECUTE ON FUNCTION public.protect_skill_default_flag() FROM anon, authenticated, public;
REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public;