mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 04:16:40 +00:00
Applied default skills hardening
X-Lovable-Edit-ID: edt-6d6c0346-b8bc-44ea-a630-43a989a99ad8 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
2782e0d182
8 changed files with 167 additions and 84 deletions
|
|
@ -191,11 +191,19 @@ export function SkillCard({ skill }: { skill: Skill }) {
|
|||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<SkillStatusBadge status={skill.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
<SkillStatusBadge status={skill.status} />
|
||||
{(skill as Skill & { is_default?: boolean }).is_default && (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Padrão
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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}>
|
||||
|
|
|
|||
|
|
@ -76,25 +76,23 @@ export function SkillTestPanel({
|
|||
|
||||
const runTest = useMutation({
|
||||
mutationFn: async (): Promise<TestResult> => {
|
||||
const start = Date.now();
|
||||
const requestBody: Record<string, unknown> = { test_input: input };
|
||||
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;
|
||||
requestBody.markdown_content = stateless.markdown_content;
|
||||
} else {
|
||||
requestBody.skill_version_id = skillVersionId;
|
||||
}
|
||||
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
|
||||
body: { skill_version_id: skillVersionId, test_input: input },
|
||||
body: requestBody,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
if (error) {
|
||||
return {
|
||||
status: "error",
|
||||
duration_ms: Date.now() - start,
|
||||
error_message: error.message,
|
||||
};
|
||||
}
|
||||
return data as TestResult;
|
||||
},
|
||||
onSuccess: (r) => {
|
||||
|
|
@ -110,6 +108,7 @@ export function SkillTestPanel({
|
|||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -67,10 +67,11 @@ 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} skills personalizadas
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ function SkillPreviewPage() {
|
|||
open={testOpen}
|
||||
onOpenChange={setTestOpen}
|
||||
skillName={(formInputs as Record<string, string>).name || "Skill"}
|
||||
skillVersionId="preview"
|
||||
skillVersionId=""
|
||||
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
|
||||
stateless={{ markdown_content: markdown }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -247,9 +247,11 @@ export async function ensureDefaultSkillsForAgent(
|
|||
description: template.description,
|
||||
trigger_keywords: template.trigger_keywords,
|
||||
status: "draft",
|
||||
is_default: true,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
const skill = skillData as { id: string } | null;
|
||||
|
||||
if (skillErr || !skill) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,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; test_input?: string; markdown_content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
|
|
@ -44,59 +44,94 @@ Deno.serve(async (req) => {
|
|||
});
|
||||
}
|
||||
|
||||
const { skill_version_id, test_input } = body;
|
||||
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
|
||||
const { skill_version_id, test_input, markdown_content } = body;
|
||||
if (!test_input || test_input.trim().length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
|
||||
JSON.stringify({ error: "test_input é obrigatório" }),
|
||||
{ status: 400, 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)
|
||||
.maybeSingle();
|
||||
// Stateless mode: markdown_content direto (preview de nova skill, sem persistência)
|
||||
const isStateless =
|
||||
!!markdown_content &&
|
||||
markdown_content.trim().length > 0 &&
|
||||
(!skill_version_id || skill_version_id === "preview");
|
||||
|
||||
if (vErr || !versionRow) {
|
||||
return new Response(JSON.stringify({ error: "Versão não encontrada" }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// @ts-expect-error supabase nested type
|
||||
if (versionRow.skills.user_id !== userId) {
|
||||
return new Response(JSON.stringify({ error: "Acesso negado" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
let resolvedMarkdown: string | null = null;
|
||||
let persistedVersionId: string | null = null;
|
||||
|
||||
if (isStateless) {
|
||||
if (markdown_content!.length > 50000) {
|
||||
return new Response(JSON.stringify({ error: "markdown_content excede 50.000 caracteres" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
resolvedMarkdown = markdown_content!;
|
||||
} else {
|
||||
if (!skill_version_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
|
||||
{ status: 400, 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)
|
||||
.maybeSingle();
|
||||
|
||||
if (vErr || !versionRow) {
|
||||
return new Response(JSON.stringify({ error: "Versão não encontrada" }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// @ts-expect-error supabase nested type
|
||||
if (versionRow.skills.user_id !== userId) {
|
||||
return new Response(JSON.stringify({ error: "Acesso negado" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
resolvedMarkdown = versionRow.markdown_content as string;
|
||||
persistedVersionId = skill_version_id;
|
||||
}
|
||||
|
||||
// Cria registro running
|
||||
const { data: runRow, error: runErr } = await admin
|
||||
.from("skill_test_runs")
|
||||
.insert({
|
||||
skill_version_id,
|
||||
user_id: userId,
|
||||
test_input,
|
||||
status: "running",
|
||||
test_type: "dry_run",
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
// Cria registro running apenas no modo persistido
|
||||
let runId: string | null = null;
|
||||
if (persistedVersionId) {
|
||||
const { data: runRow, error: runErr } = await admin
|
||||
.from("skill_test_runs")
|
||||
.insert({
|
||||
skill_version_id: persistedVersionId,
|
||||
user_id: userId,
|
||||
test_input,
|
||||
status: "running",
|
||||
test_type: "dry_run",
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (runErr || !runRow) {
|
||||
console.error("Failed to create test run:", runErr);
|
||||
return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
if (runErr || !runRow) {
|
||||
console.error("Failed to create test run:", runErr);
|
||||
return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
runId = runRow.id;
|
||||
}
|
||||
|
||||
const runId = runRow.id;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const updateRun = async (patch: Record<string, unknown>) => {
|
||||
if (!runId) return;
|
||||
await admin.from("skill_test_runs").update(patch).eq("id", runId);
|
||||
};
|
||||
|
||||
try {
|
||||
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
|
|
@ -110,7 +145,7 @@ Deno.serve(async (req) => {
|
|||
{ 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}`,
|
||||
content: `Definição da skill:\n\`\`\`\n${resolvedMarkdown}\n\`\`\`\n\nInput do usuário: ${test_input}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -118,14 +153,11 @@ Deno.serve(async (req) => {
|
|||
|
||||
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);
|
||||
await updateRun({
|
||||
status: "error",
|
||||
error_message: "Muitas requisições. Aguarde 1 minuto.",
|
||||
duration_ms: duration,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
|
||||
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
|
|
@ -135,14 +167,11 @@ Deno.serve(async (req) => {
|
|||
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);
|
||||
await updateRun({
|
||||
status: "error",
|
||||
error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
|
||||
duration_ms: duration,
|
||||
});
|
||||
return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
|
|
@ -153,10 +182,7 @@ Deno.serve(async (req) => {
|
|||
const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
|
||||
const duration = Date.now() - startedAt;
|
||||
|
||||
await admin
|
||||
.from("skill_test_runs")
|
||||
.update({ status: "success", test_output, duration_ms: duration })
|
||||
.eq("id", runId);
|
||||
await updateRun({ status: "success", test_output, duration_ms: duration });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
|
||||
|
|
@ -165,13 +191,11 @@ Deno.serve(async (req) => {
|
|||
} catch (e) {
|
||||
const duration = Date.now() - startedAt;
|
||||
const msg = e instanceof Error ? e.message : "Erro desconhecido";
|
||||
await admin
|
||||
.from("skill_test_runs")
|
||||
.update({ status: "error", error_message: msg, duration_ms: duration })
|
||||
.eq("id", runId);
|
||||
await updateRun({ status: "error", error_message: msg, duration_ms: duration });
|
||||
return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
-- Add is_default column to skills and update user_skill_limits view
|
||||
-- to exclude default skills from custom skill quota
|
||||
ALTER TABLE public.skills
|
||||
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Mark known default skills by name (where description matches the template too)
|
||||
UPDATE public.skills
|
||||
SET is_default = true
|
||||
WHERE is_default = false
|
||||
AND name IN ('Resumo diario', 'Planejamento semanal', 'Preparar reuniao')
|
||||
AND description IN (
|
||||
'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.',
|
||||
'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.',
|
||||
'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
|
||||
);
|
||||
|
||||
-- Recreate user_skill_limits to exclude default skills from quota count
|
||||
DROP VIEW IF EXISTS public.user_skill_limits;
|
||||
|
||||
CREATE VIEW public.user_skill_limits AS
|
||||
SELECT
|
||||
p.id AS user_id,
|
||||
pl.slug AS plan_slug,
|
||||
CASE
|
||||
WHEN pl.slug IS NULL THEN NULL::integer
|
||||
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::integer
|
||||
END AS max_skills,
|
||||
(
|
||||
SELECT count(*)::integer
|
||||
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 = ANY (ARRAY['active'::text, 'trialing'::text])
|
||||
LEFT JOIN public.plans pl ON pl.id = s.plan_id;
|
||||
|
||||
GRANT SELECT ON public.user_skill_limits TO authenticated;
|
||||
GRANT SELECT ON public.user_skill_limits TO service_role;
|
||||
Loading…
Add table
Add a link
Reference in a new issue