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>
<p className="text-xs text-muted-foreground">
{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>
</div>
</div>
@ -67,7 +69,11 @@ export function SkillsDashboardWidget() {
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{skill.name}</p>
<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>
</div>
<SkillStatusBadge status={skill.status} className="shrink-0" />

View file

@ -10,6 +10,7 @@ import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { syncAgentSkills } from "@/lib/sync-agent-skills";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@ -128,9 +129,7 @@ export function SkillCard({ skill }: { skill: Skill }) {
>
{skill.name}
</button>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{skill.description}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -191,7 +190,14 @@ export function SkillCard({ skill }: { skill: Skill }) {
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<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">
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
</span>
@ -203,8 +209,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
<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.
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>
@ -219,8 +225,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
<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.
Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões. Não é
possível desfazer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -6,20 +6,11 @@ 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 { 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";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface Props {
open: boolean;
@ -80,7 +71,7 @@ export function SkillTestPanel({
// 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 },
body: { markdown_content: stateless.markdown_content, test_input: input },
});
if (error) {
return {
@ -122,9 +113,7 @@ export function SkillTestPanel({
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-2">
Digite um exemplo de input
</label>
<label className="text-sm font-medium block mb-2">Digite um exemplo de input</label>
<Textarea
ref={inputRef}
value={input}
@ -166,12 +155,11 @@ export function SkillTestPanel({
<h3 className="font-semibold text-sm">
{result.status === "success" ? "Resultado do teste" : "Erro no teste"}
</h3>
{result.status === "success" && (
<Badge variant="info">
Dry-run
</Badge>
)}
<button onClick={() => setResult(null)} className="ml-auto text-muted-foreground hover:text-foreground">
{result.status === "success" && <Badge variant="info">Dry-run</Badge>}
<button
onClick={() => setResult(null)}
className="ml-auto text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
@ -193,7 +181,10 @@ export function SkillTestPanel({
</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
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"
@ -206,7 +197,10 @@ export function SkillTestPanel({
{run.status === "success" ? "Sucesso" : "Erro"}
</Badge>
<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>
</div>
<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">
O teste em modo simulação não executa ferramentas reais. Em breve você poderá
testar a skill diretamente no seu agente.
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>

View file

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

View file

@ -19,7 +19,10 @@ export const Route = createFileRoute("/painel/agente")({
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 },
active: { label: "Online", variant: "success" },
suspended: { label: "Suspenso", variant: "destructive" },
@ -68,20 +71,22 @@ function AgentePage() {
<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>
<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(
<div
className={cn(
"h-14 w-14 rounded-full flex items-center justify-center shrink-0",
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 className="flex-1">
<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">
<StatItem label="Interações hoje" value="0" />
<StatItem
label="Skills ativas"
label="Skills personalizadas"
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 })
? formatDistanceToNow(new Date(lastTestRun.data), {
addSuffix: true,
locale: ptBR,
})
: "—"
}
/>

View file

@ -3,7 +3,18 @@
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 {
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";
@ -81,23 +92,20 @@ function SkillDetailPage() {
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];
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) => {
const selectVersion = useCallback((v: SkillVersion) => {
setSelectedVersionId(v.id);
setMarkdown(v.markdown_content);
setEditing(false);
},
[],
);
}, []);
// Save new version
const saveVersion = useMutation({
@ -106,7 +114,8 @@ function SkillDetailPage() {
const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
const { data, error } = await supabase
.from("skill_versions")
.insert([{
.insert([
{
skill_id: id,
version_number: maxVer + 1,
markdown_content: markdown,
@ -114,7 +123,8 @@ function SkillDetailPage() {
form_inputs: (currentVersion?.form_inputs ?? {}) as any,
is_live: false,
created_by: user.id,
}])
},
])
.select("id, version_number")
.single();
if (error) throw error;
@ -234,6 +244,11 @@ function SkillDetailPage() {
<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} />
{skill.data.is_default && (
<Badge variant="muted" className="rounded-md font-medium">
Padrão
</Badge>
)}
{currentVersion && (
<span className="text-xs text-muted-foreground">
v{currentVersion.version_number}
@ -323,7 +338,10 @@ function SkillDetailPage() {
<Suspense fallback={<Skeleton className="h-60" />}>
<CodeMirrorEditor
value={markdown}
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
onChange={(v) => {
setMarkdown(v);
if (!editing) setEditing(true);
}}
readOnly={!editing}
/>
</Suspense>
@ -336,15 +354,22 @@ function SkillDetailPage() {
<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>
<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); }}
onChange={(v) => {
setMarkdown(v);
if (!editing) setEditing(true);
}}
readOnly={!editing}
/>
</Suspense>

View file

@ -14,11 +14,7 @@ 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";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export const Route = createFileRoute("/painel/skills/")({
component: SkillsPage,
@ -31,10 +27,8 @@ function SkillsPage() {
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 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 &&
@ -67,7 +61,7 @@ function SkillsPage() {
<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
{limits.data.current_skills_count} de {limits.data.max_skills} personalizadas
</span>
)}
@ -102,7 +96,7 @@ function SkillsPage() {
<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.`}
: `Limite de ${limits.data?.max_skills} skills personalizadas do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
</TooltipContent>
)}
</Tooltip>

View file

@ -45,7 +45,8 @@ function SkillPreviewPage() {
const hasContent = markdown.trim().length > 0;
// 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") {
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
@ -53,7 +54,9 @@ function SkillPreviewPage() {
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.");
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") {
@ -61,9 +64,12 @@ function SkillPreviewPage() {
return true;
}
return false;
}, [navigate]);
},
[navigate],
);
const createSkill = useCallback(async (publish: boolean) => {
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.");
@ -99,7 +105,8 @@ function SkillPreviewPage() {
// 2. Create version
const { data: ver, error: verErr } = await supabase
.from("skill_versions")
.insert([{
.insert([
{
skill_id: skill.id,
version_number: 1,
markdown_content: markdown,
@ -107,11 +114,13 @@ function SkillPreviewPage() {
form_inputs: formInputs as any,
is_live: false,
created_by: user.id,
}])
},
])
.select("id")
.single();
if (verErr) {
await supabase.from("skills").delete().eq("id", skill.id);
toast.error(verErr.message || "Erro ao salvar versão");
return;
}
@ -122,10 +131,7 @@ function SkillPreviewPage() {
success?: boolean;
synced?: boolean;
sync_error?: string;
}>(
"publish-skill-version",
{ body: { skill_version_id: ver.id } },
);
}>("publish-skill-version", { body: { skill_version_id: ver.id } });
if (pubErr) {
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
} else if (pubData?.synced === false) {
@ -147,7 +153,9 @@ function SkillPreviewPage() {
} finally {
setter(false);
}
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
},
[user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc],
);
if (!routerState.markdown_content) {
return (
@ -175,8 +183,16 @@ function SkillPreviewPage() {
<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" />}
<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
@ -184,7 +200,11 @@ function SkillPreviewPage() {
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" />}
{publishing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
Publicar agora
</Button>
</div>

View file

@ -220,11 +220,16 @@ export async function ensureDefaultSkillsForAgent(
for (const template of DEFAULT_HERMES_SKILLS) {
const { data: existing, error: existingErr } = await supabase
.from("skills")
.select("id")
.select("id, current_version_id, is_default")
.eq("user_id", agent.user_id)
.eq("name", template.name)
.neq("status", "archived")
.maybeSingle();
const existingSkill = existing as {
id: string;
current_version_id: string | null;
is_default?: boolean | null;
} | null;
if (existingErr) {
result.errors.push(
@ -233,11 +238,20 @@ export async function ensureDefaultSkillsForAgent(
continue;
}
if (existing) {
if (existingSkill?.current_version_id) {
result.skipped_count += 1;
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
.from("skills")
.insert({
@ -246,6 +260,7 @@ export async function ensureDefaultSkillsForAgent(
name: template.name,
description: template.description,
trigger_keywords: template.trigger_keywords,
is_default: true,
status: "draft",
})
.select("id")
@ -274,6 +289,7 @@ export async function ensureDefaultSkillsForAgent(
const version = versionData as { id: string } | null;
if (versionErr || !version) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
);
@ -290,6 +306,7 @@ export async function ensureDefaultSkillsForAgent(
.eq("id", skill.id);
if (updateErr) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
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);
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) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
@ -34,7 +85,7 @@ Deno.serve(async (req) => {
}
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 {
body = await req.json();
} catch {
@ -44,19 +95,57 @@ Deno.serve(async (req) => {
});
}
const { skill_version_id, test_input } = body;
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
const skillVersionId =
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(
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" } },
);
}
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
const { data: versionRow, error: vErr } = await admin
.from("skill_versions")
.select("id, markdown_content, skills!inner(user_id)")
.eq("id", skill_version_id)
.eq("id", skillVersionId)
.maybeSingle();
if (vErr || !versionRow) {
@ -77,9 +166,9 @@ Deno.serve(async (req) => {
const { data: runRow, error: runErr } = await admin
.from("skill_test_runs")
.insert({
skill_version_id,
skill_version_id: skillVersionId,
user_id: userId,
test_input,
test_input: testInput,
status: "running",
test_type: "dry_run",
})
@ -98,59 +187,7 @@ Deno.serve(async (req) => {
const startedAt = Date.now();
try {
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${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 test_output = await generateDryRunOutput(versionRow.markdown_content, testInput);
const duration = Date.now() - startedAt;
await admin
@ -158,10 +195,10 @@ Deno.serve(async (req) => {
.update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId);
return new Response(
JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
return new Response(JSON.stringify({ test_output, duration_ms: duration, status: "success" }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (e) {
const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido";
@ -169,9 +206,14 @@ Deno.serve(async (req) => {
.from("skill_test_runs")
.update({ status: "error", error_message: msg, duration_ms: duration })
.eq("id", runId);
return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
status: 500,
return new Response(
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" },
});
},
);
}
});

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;