mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Criou hooks, componentes e páginas
X-Lovable-Edit-ID: edt-8ffe4eb2-9849-42da-a15f-1494f856a334 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
8634725abf
21 changed files with 2167 additions and 3 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -13,6 +13,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.25.5",
|
"@cloudflare/vite-plugin": "^1.25.5",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@lovable.dev/cloud-auth-js": "^1.1.1",
|
"@lovable.dev/cloud-auth-js": "^1.1.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
|
@ -47,6 +49,7 @@
|
||||||
"@tanstack/react-router": "^1.168.0",
|
"@tanstack/react-router": "^1.168.0",
|
||||||
"@tanstack/react-start": "^1.167.14",
|
"@tanstack/react-start": "^1.167.14",
|
||||||
"@tanstack/router-plugin": "^1.167.10",
|
"@tanstack/router-plugin": "^1.167.10",
|
||||||
|
"@uiw/react-codemirror": "^4.25.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|
@ -60,8 +63,10 @@
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^4.6.5",
|
"react-resizable-panels": "^4.6.5",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|
|
||||||
18
src/components/mika/skills/AgentProvisioningState.tsx
Normal file
18
src/components/mika/skills/AgentProvisioningState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/mika/skills/EmptySkillsState.tsx
Normal file
28
src/components/mika/skills/EmptySkillsState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/mika/skills/NoSubscriptionState.tsx
Normal file
31
src/components/mika/skills/NoSubscriptionState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/mika/skills/SkillCard.tsx
Normal file
230
src/components/mika/skills/SkillCard.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/mika/skills/SkillMarkdownEditor.tsx
Normal file
59
src/components/mika/skills/SkillMarkdownEditor.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { markdown as markdownLang } from "@codemirror/lang-markdown";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light theme — matches Mika design system
|
||||||
|
const mikaLight = EditorView.theme({
|
||||||
|
"&": { backgroundColor: "var(--color-card)", color: "var(--color-foreground)" },
|
||||||
|
".cm-content": { caretColor: "var(--color-primary)" },
|
||||||
|
".cm-activeLine": { backgroundColor: "var(--color-muted)" },
|
||||||
|
".cm-selectionBackground, ::selection": { backgroundColor: "oklch(0.70 0.19 47 / 0.25) !important" },
|
||||||
|
".cm-gutters": { backgroundColor: "var(--color-card)", borderRight: "1px solid var(--color-border)" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SkillMarkdownEditor({ value, onChange, readOnly = false }: Props) {
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const check = () => setIsDark(html.classList.contains("dark"));
|
||||||
|
check();
|
||||||
|
const obs = new MutationObserver(check);
|
||||||
|
obs.observe(html, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [markdownLang(), EditorView.lineWrapping],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(val: string) => onChange(val),
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
extensions={extensions}
|
||||||
|
theme={isDark ? oneDark : mikaLight}
|
||||||
|
readOnly={readOnly}
|
||||||
|
height="100%"
|
||||||
|
minHeight="300px"
|
||||||
|
maxHeight="80vh"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/mika/skills/SkillStatusBadge.tsx
Normal file
35
src/components/mika/skills/SkillStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/components/mika/skills/SkillTestPanel.tsx
Normal file
227
src/components/mika/skills/SkillTestPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/hooks/use-agent-instance.ts
Normal file
30
src/hooks/use-agent-instance.ts
Normal 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
46
src/hooks/use-skills.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
29
src/hooks/use-user-skill-limits.ts
Normal file
29
src/hooks/use-user-skill-limits.ts
Normal 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
47
src/lib/skill-schema.ts
Normal 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;
|
||||||
|
|
@ -16,9 +16,15 @@ import { Route as PainelRouteImport } from './routes/painel'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as PainelIndexRouteImport } from './routes/painel.index'
|
import { Route as PainelIndexRouteImport } from './routes/painel.index'
|
||||||
|
import { Route as PainelSkillsRouteImport } from './routes/painel.skills'
|
||||||
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
|
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
|
||||||
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
|
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
|
||||||
|
import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
|
||||||
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
|
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
|
||||||
|
import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index'
|
||||||
|
import { Route as PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview'
|
||||||
|
import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
|
||||||
|
import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SignupRoute = SignupRouteImport.update({
|
||||||
id: '/signup',
|
id: '/signup',
|
||||||
|
|
@ -55,6 +61,11 @@ const PainelIndexRoute = PainelIndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => PainelRoute,
|
getParentRoute: () => PainelRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PainelSkillsRoute = PainelSkillsRouteImport.update({
|
||||||
|
id: '/skills',
|
||||||
|
path: '/skills',
|
||||||
|
getParentRoute: () => PainelRoute,
|
||||||
|
} as any)
|
||||||
const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({
|
const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({
|
||||||
id: '/faturamento',
|
id: '/faturamento',
|
||||||
path: '/faturamento',
|
path: '/faturamento',
|
||||||
|
|
@ -65,11 +76,36 @@ const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({
|
||||||
path: '/configuracoes',
|
path: '/configuracoes',
|
||||||
getParentRoute: () => PainelRoute,
|
getParentRoute: () => PainelRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PainelAgenteRoute = PainelAgenteRouteImport.update({
|
||||||
|
id: '/agente',
|
||||||
|
path: '/agente',
|
||||||
|
getParentRoute: () => PainelRoute,
|
||||||
|
} as any)
|
||||||
const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({
|
const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({
|
||||||
id: '/checkout/sucesso',
|
id: '/checkout/sucesso',
|
||||||
path: '/checkout/sucesso',
|
path: '/checkout/sucesso',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PainelSkillsIndexRoute = PainelSkillsIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
|
} as any)
|
||||||
|
const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
|
||||||
|
id: '/preview',
|
||||||
|
path: '/preview',
|
||||||
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
|
} as any)
|
||||||
|
const PainelSkillsNovaRoute = PainelSkillsNovaRouteImport.update({
|
||||||
|
id: '/nova',
|
||||||
|
path: '/nova',
|
||||||
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
|
} as any)
|
||||||
|
const PainelSkillsIdRoute = PainelSkillsIdRouteImport.update({
|
||||||
|
id: '/$id',
|
||||||
|
path: '/$id',
|
||||||
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -79,9 +115,15 @@ export interface FileRoutesByFullPath {
|
||||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||||
|
'/painel/agente': typeof PainelAgenteRoute
|
||||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/skills/': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -90,9 +132,14 @@ export interface FileRoutesByTo {
|
||||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||||
|
'/painel/agente': typeof PainelAgenteRoute
|
||||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel': typeof PainelIndexRoute
|
'/painel': typeof PainelIndexRoute
|
||||||
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/skills': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
|
|
@ -103,9 +150,15 @@ export interface FileRoutesById {
|
||||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||||
|
'/painel/agente': typeof PainelAgenteRoute
|
||||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/skills/': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -117,9 +170,15 @@ export interface FileRouteTypes {
|
||||||
| '/redefinir-senha'
|
| '/redefinir-senha'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/checkout/sucesso'
|
| '/checkout/sucesso'
|
||||||
|
| '/painel/agente'
|
||||||
| '/painel/configuracoes'
|
| '/painel/configuracoes'
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/painel/skills/$id'
|
||||||
|
| '/painel/skills/nova'
|
||||||
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/skills/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -128,9 +187,14 @@ export interface FileRouteTypes {
|
||||||
| '/redefinir-senha'
|
| '/redefinir-senha'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/checkout/sucesso'
|
| '/checkout/sucesso'
|
||||||
|
| '/painel/agente'
|
||||||
| '/painel/configuracoes'
|
| '/painel/configuracoes'
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel'
|
| '/painel'
|
||||||
|
| '/painel/skills/$id'
|
||||||
|
| '/painel/skills/nova'
|
||||||
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/skills'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -140,9 +204,15 @@ export interface FileRouteTypes {
|
||||||
| '/redefinir-senha'
|
| '/redefinir-senha'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/checkout/sucesso'
|
| '/checkout/sucesso'
|
||||||
|
| '/painel/agente'
|
||||||
| '/painel/configuracoes'
|
| '/painel/configuracoes'
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/painel/skills/$id'
|
||||||
|
| '/painel/skills/nova'
|
||||||
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/skills/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
|
|
@ -206,6 +276,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PainelIndexRouteImport
|
preLoaderRoute: typeof PainelIndexRouteImport
|
||||||
parentRoute: typeof PainelRoute
|
parentRoute: typeof PainelRoute
|
||||||
}
|
}
|
||||||
|
'/painel/skills': {
|
||||||
|
id: '/painel/skills'
|
||||||
|
path: '/skills'
|
||||||
|
fullPath: '/painel/skills'
|
||||||
|
preLoaderRoute: typeof PainelSkillsRouteImport
|
||||||
|
parentRoute: typeof PainelRoute
|
||||||
|
}
|
||||||
'/painel/faturamento': {
|
'/painel/faturamento': {
|
||||||
id: '/painel/faturamento'
|
id: '/painel/faturamento'
|
||||||
path: '/faturamento'
|
path: '/faturamento'
|
||||||
|
|
@ -220,6 +297,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PainelConfiguracoesRouteImport
|
preLoaderRoute: typeof PainelConfiguracoesRouteImport
|
||||||
parentRoute: typeof PainelRoute
|
parentRoute: typeof PainelRoute
|
||||||
}
|
}
|
||||||
|
'/painel/agente': {
|
||||||
|
id: '/painel/agente'
|
||||||
|
path: '/agente'
|
||||||
|
fullPath: '/painel/agente'
|
||||||
|
preLoaderRoute: typeof PainelAgenteRouteImport
|
||||||
|
parentRoute: typeof PainelRoute
|
||||||
|
}
|
||||||
'/checkout/sucesso': {
|
'/checkout/sucesso': {
|
||||||
id: '/checkout/sucesso'
|
id: '/checkout/sucesso'
|
||||||
path: '/checkout/sucesso'
|
path: '/checkout/sucesso'
|
||||||
|
|
@ -227,18 +311,68 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof CheckoutSucessoRouteImport
|
preLoaderRoute: typeof CheckoutSucessoRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/painel/skills/': {
|
||||||
|
id: '/painel/skills/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/painel/skills/'
|
||||||
|
preLoaderRoute: typeof PainelSkillsIndexRouteImport
|
||||||
|
parentRoute: typeof PainelSkillsRoute
|
||||||
|
}
|
||||||
|
'/painel/skills/preview': {
|
||||||
|
id: '/painel/skills/preview'
|
||||||
|
path: '/preview'
|
||||||
|
fullPath: '/painel/skills/preview'
|
||||||
|
preLoaderRoute: typeof PainelSkillsPreviewRouteImport
|
||||||
|
parentRoute: typeof PainelSkillsRoute
|
||||||
|
}
|
||||||
|
'/painel/skills/nova': {
|
||||||
|
id: '/painel/skills/nova'
|
||||||
|
path: '/nova'
|
||||||
|
fullPath: '/painel/skills/nova'
|
||||||
|
preLoaderRoute: typeof PainelSkillsNovaRouteImport
|
||||||
|
parentRoute: typeof PainelSkillsRoute
|
||||||
|
}
|
||||||
|
'/painel/skills/$id': {
|
||||||
|
id: '/painel/skills/$id'
|
||||||
|
path: '/$id'
|
||||||
|
fullPath: '/painel/skills/$id'
|
||||||
|
preLoaderRoute: typeof PainelSkillsIdRouteImport
|
||||||
|
parentRoute: typeof PainelSkillsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PainelSkillsRouteChildren {
|
||||||
|
PainelSkillsIdRoute: typeof PainelSkillsIdRoute
|
||||||
|
PainelSkillsNovaRoute: typeof PainelSkillsNovaRoute
|
||||||
|
PainelSkillsPreviewRoute: typeof PainelSkillsPreviewRoute
|
||||||
|
PainelSkillsIndexRoute: typeof PainelSkillsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const PainelSkillsRouteChildren: PainelSkillsRouteChildren = {
|
||||||
|
PainelSkillsIdRoute: PainelSkillsIdRoute,
|
||||||
|
PainelSkillsNovaRoute: PainelSkillsNovaRoute,
|
||||||
|
PainelSkillsPreviewRoute: PainelSkillsPreviewRoute,
|
||||||
|
PainelSkillsIndexRoute: PainelSkillsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PainelSkillsRouteWithChildren = PainelSkillsRoute._addFileChildren(
|
||||||
|
PainelSkillsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface PainelRouteChildren {
|
interface PainelRouteChildren {
|
||||||
|
PainelAgenteRoute: typeof PainelAgenteRoute
|
||||||
PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute
|
PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute
|
||||||
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
|
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
|
||||||
|
PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
|
||||||
PainelIndexRoute: typeof PainelIndexRoute
|
PainelIndexRoute: typeof PainelIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const PainelRouteChildren: PainelRouteChildren = {
|
const PainelRouteChildren: PainelRouteChildren = {
|
||||||
|
PainelAgenteRoute: PainelAgenteRoute,
|
||||||
PainelConfiguracoesRoute: PainelConfiguracoesRoute,
|
PainelConfiguracoesRoute: PainelConfiguracoesRoute,
|
||||||
PainelFaturamentoRoute: PainelFaturamentoRoute,
|
PainelFaturamentoRoute: PainelFaturamentoRoute,
|
||||||
|
PainelSkillsRoute: PainelSkillsRouteWithChildren,
|
||||||
PainelIndexRoute: PainelIndexRoute,
|
PainelIndexRoute: PainelIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
180
src/routes/painel.agente.tsx
Normal file
180
src/routes/painel.agente.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { Bot, Cpu, MessageSquare, Sparkles, BarChart3, Loader2 } from "lucide-react";
|
||||||
|
import { useProfile } from "@/hooks/use-profile";
|
||||||
|
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||||
|
import { useUserSkillLimits } from "@/hooks/use-user-skill-limits";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/agente")({
|
||||||
|
component: AgentePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string; pulse?: boolean }> = {
|
||||||
|
provisioning: { label: "Provisionando", color: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", pulse: true },
|
||||||
|
active: { label: "Online", color: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30" },
|
||||||
|
suspended: { label: "Suspenso", color: "bg-destructive/15 text-destructive border-destructive/30" },
|
||||||
|
error: { label: "Erro", color: "bg-destructive/15 text-destructive border-destructive/30" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function AgentePage() {
|
||||||
|
const { data: profile } = useProfile();
|
||||||
|
const agent = useAgentInstance();
|
||||||
|
const limits = useUserSkillLimits();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const lastTestRun = useQuery({
|
||||||
|
queryKey: ["last-test-run", user?.id],
|
||||||
|
enabled: !!user,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("skill_test_runs")
|
||||||
|
.select("created_at")
|
||||||
|
.eq("user_id", user!.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data?.created_at ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = agent.isLoading;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-12 w-1/3" />
|
||||||
|
<Skeleton className="h-40 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = (profile?.full_name || "").split(" ")[0] || "Você";
|
||||||
|
const agentName = `Mika de ${firstName}`;
|
||||||
|
const status = agent.data?.status ?? "provisioning";
|
||||||
|
const statusInfo = STATUS_MAP[status] ?? STATUS_MAP.provisioning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Meu Agente</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Gerencie seu agente pessoal de IA
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Card 1: Seu agente — full width */}
|
||||||
|
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
"h-14 w-14 rounded-full flex items-center justify-center shrink-0",
|
||||||
|
status === "active" ? "bg-emerald-500/10" : "bg-primary/10",
|
||||||
|
)}>
|
||||||
|
<Bot className={cn("h-7 w-7", status === "active" ? "text-emerald-500" : "text-primary")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-xl font-bold">{agentName}</h2>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(statusInfo.color, statusInfo.pulse && "animate-pulse")}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{status === "provisioning"
|
||||||
|
? "Estamos preparando sua instância. Geralmente leva até 10 minutos."
|
||||||
|
: status === "active"
|
||||||
|
? "Seu agente está online e pronto para receber skills."
|
||||||
|
: "Houve um problema com sua instância. Entre em contato com o suporte."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Modelo de IA */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Cpu className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="font-semibold">Modelo de IA</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">Opencode Zen</p>
|
||||||
|
<Badge variant="outline" className="mt-2 text-xs">
|
||||||
|
Padrão do plano
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Canais conectados */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<MessageSquare className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="font-semibold">Canais conectados</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">Telegram</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Conectar
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Disponível em breve</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: Estatísticas */}
|
||||||
|
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="font-semibold">Estatísticas</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<StatItem label="Interações hoje" value="0" />
|
||||||
|
<StatItem
|
||||||
|
label="Skills ativas"
|
||||||
|
value={limits.isLoading ? "..." : String(limits.data?.current_skills_count ?? 0)}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Último teste"
|
||||||
|
value={
|
||||||
|
lastTestRun.data
|
||||||
|
? formatDistanceToNow(new Date(lastTestRun.data), { addSuffix: true, locale: ptBR })
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatItem label="Uptime" value="—" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
src/routes/painel.skills.$id.tsx
Normal file
412
src/routes/painel.skills.$id.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
|
||||||
|
import { useState, useCallback, lazy, Suspense } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useSkill } from "@/hooks/use-skills";
|
||||||
|
import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge";
|
||||||
|
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/skills/$id")({
|
||||||
|
component: SkillDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SkillVersion {
|
||||||
|
id: string;
|
||||||
|
version_number: number;
|
||||||
|
markdown_content: string;
|
||||||
|
form_inputs: Record<string, unknown>;
|
||||||
|
is_live: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillDetailPage() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const skill = useSkill(id);
|
||||||
|
|
||||||
|
const versions = useQuery({
|
||||||
|
queryKey: ["skill-versions", id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async (): Promise<SkillVersion[]> => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("skill_versions")
|
||||||
|
.select("id, version_number, markdown_content, form_inputs, is_live, created_at")
|
||||||
|
.eq("skill_id", id)
|
||||||
|
.order("version_number", { ascending: false })
|
||||||
|
.limit(12);
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []) as SkillVersion[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
const [markdown, setMarkdown] = useState("");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [testOpen, setTestOpen] = useState(false);
|
||||||
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||||
|
|
||||||
|
// Sync markdown when versions load or selection changes
|
||||||
|
const currentVersion = versions.data?.find((v) =>
|
||||||
|
selectedVersionId ? v.id === selectedVersionId : v.is_live,
|
||||||
|
) ?? versions.data?.[0];
|
||||||
|
|
||||||
|
if (currentVersion && markdown === "" && !editing) {
|
||||||
|
// initial load
|
||||||
|
setTimeout(() => setMarkdown(currentVersion.markdown_content), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectVersion = useCallback(
|
||||||
|
(v: SkillVersion) => {
|
||||||
|
setSelectedVersionId(v.id);
|
||||||
|
setMarkdown(v.markdown_content);
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save new version
|
||||||
|
const saveVersion = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!user || !versions.data) throw new Error("Dados indisponíveis");
|
||||||
|
const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("skill_versions")
|
||||||
|
.insert([{
|
||||||
|
skill_id: id,
|
||||||
|
version_number: maxVer + 1,
|
||||||
|
markdown_content: markdown,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form_inputs: (currentVersion?.form_inputs ?? {}) as any,
|
||||||
|
is_live: false,
|
||||||
|
created_by: user.id,
|
||||||
|
}])
|
||||||
|
.select("id, version_number")
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Versão ${data.version_number} salva`);
|
||||||
|
setSelectedVersionId(data.id);
|
||||||
|
setEditing(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ["skill-versions", id] });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao salvar"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish version
|
||||||
|
const publishVersion = useMutation({
|
||||||
|
mutationFn: async (versionId: string) => {
|
||||||
|
const { data, error } = await supabase.functions.invoke("publish-skill-version", {
|
||||||
|
body: { skill_version_id: versionId },
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.no_op) {
|
||||||
|
toast.info("Esta versão já está publicada");
|
||||||
|
} else {
|
||||||
|
toast.success(`Versão ${data.version_number} publicada!`);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["skill-versions", id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["skill", id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => {
|
||||||
|
const msg = e instanceof Error ? e.message : "Erro";
|
||||||
|
if (msg.includes("409")) {
|
||||||
|
toast.error("Conflito de concorrência. Recarregue e tente novamente.");
|
||||||
|
} else {
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive
|
||||||
|
const archiveSkill = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("skills")
|
||||||
|
.update({ status: "archived", updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", id);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Skill arquivada");
|
||||||
|
qc.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user-limits"] });
|
||||||
|
navigate({ to: "/painel/skills" });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = skill.isLoading || versions.isLoading;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-1/3" />
|
||||||
|
<Skeleton className="h-[60vh] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skill.data) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-muted-foreground">Skill não encontrada.</p>
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/painel/skills">Voltar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentLive = currentVersion?.is_live === true;
|
||||||
|
const hasChanged = editing && markdown !== currentVersion?.markdown_content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to="/painel/skills">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-xl font-bold truncate">{skill.data.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<SkillStatusBadge status={skill.data.status} />
|
||||||
|
{currentVersion && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
v{currentVersion.version_number}
|
||||||
|
{currentVersion.is_live && " (live)"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTestOpen(true)}
|
||||||
|
disabled={!currentVersion}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" /> Testar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{editing && hasChanged && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => saveVersion.mutate()}
|
||||||
|
disabled={saveVersion.isPending}
|
||||||
|
>
|
||||||
|
{saveVersion.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Salvar nova versão
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editing && currentVersion && !isCurrentLive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => publishVersion.mutate(currentVersion.id)}
|
||||||
|
disabled={publishVersion.isPending}
|
||||||
|
className="bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||||
|
>
|
||||||
|
{publishVersion.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Publicar esta versão
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled className="opacity-50">
|
||||||
|
<Copy className="h-4 w-4 mr-2" /> Duplicar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmArchive(true)}
|
||||||
|
className="text-destructive focus:text-destructive cursor-pointer"
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4 mr-2" /> Arquivar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-[60vh]">
|
||||||
|
{/* Editor / Preview (3 cols) */}
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
{/* Desktop split */}
|
||||||
|
<div className="hidden lg:grid lg:grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
<Suspense fallback={<Skeleton className="h-60" />}>
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={markdown}
|
||||||
|
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
|
||||||
|
readOnly={!editing}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 overflow-auto max-h-[80vh] prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile tabs */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Tabs defaultValue="preview">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="editor" className="flex-1">Editor</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview" className="flex-1">Preview</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="editor" className="mt-4">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
<Suspense fallback={<Skeleton className="h-60" />}>
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={markdown}
|
||||||
|
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
|
||||||
|
readOnly={!editing}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="preview" className="mt-4">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 overflow-auto prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline sidebar (1 col) */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 space-y-3 lg:sticky lg:top-24">
|
||||||
|
<h3 className="font-semibold text-sm">Versões</h3>
|
||||||
|
{versions.data?.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => selectVersion(v)}
|
||||||
|
className={`w-full text-left rounded-lg p-3 transition-colors text-sm ${
|
||||||
|
currentVersion?.id === v.id
|
||||||
|
? "bg-primary/10 border border-primary/30"
|
||||||
|
: "hover:bg-muted border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">v{v.version_number}</span>
|
||||||
|
{v.is_live && (
|
||||||
|
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 text-[10px]">
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatDistanceToNow(new Date(v.created_at), { addSuffix: true, locale: ptBR })}
|
||||||
|
</p>
|
||||||
|
{!v.is_live && currentVersion?.id !== v.id && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-primary hover:underline mt-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
publishVersion.mutate(v.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restaurar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test panel */}
|
||||||
|
{testOpen && currentVersion && (
|
||||||
|
<SkillTestPanel
|
||||||
|
open={testOpen}
|
||||||
|
onOpenChange={setTestOpen}
|
||||||
|
skillName={skill.data?.name ?? "Skill"}
|
||||||
|
skillVersionId={currentVersion.id}
|
||||||
|
triggerKeywords={skill.data?.trigger_keywords}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archive dialog */}
|
||||||
|
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
A skill ficará invisível para o agente. Você poderá restaurá-la depois.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => archiveSkill.mutate()}>Arquivar</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/routes/painel.skills.index.tsx
Normal file
128
src/routes/painel.skills.index.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
|
import { useSkills } from "@/hooks/use-skills";
|
||||||
|
import { useUserSkillLimits } from "@/hooks/use-user-skill-limits";
|
||||||
|
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||||
|
import { NoSubscriptionState } from "@/components/mika/skills/NoSubscriptionState";
|
||||||
|
import { AgentProvisioningState } from "@/components/mika/skills/AgentProvisioningState";
|
||||||
|
import { EmptySkillsState } from "@/components/mika/skills/EmptySkillsState";
|
||||||
|
import { SkillCard } from "@/components/mika/skills/SkillCard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/skills/")({
|
||||||
|
component: SkillsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SkillsPage() {
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const limits = useUserSkillLimits();
|
||||||
|
const agent = useAgentInstance();
|
||||||
|
const skills = useSkills(showArchived);
|
||||||
|
|
||||||
|
const loading = limits.isLoading || agent.isLoading || skills.isLoading;
|
||||||
|
const noSub = !limits.isLoading && (limits.data?.max_skills == null);
|
||||||
|
const agentNotReady =
|
||||||
|
!agent.isLoading &&
|
||||||
|
(!agent.data || agent.data.status === "provisioning");
|
||||||
|
const atLimit =
|
||||||
|
limits.data != null &&
|
||||||
|
limits.data.max_skills != null &&
|
||||||
|
limits.data.current_skills_count >= limits.data.max_skills;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-12 w-1/3" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-36 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Skills</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Crie automações personalizadas para seu agente Mika
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noSub && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{limits.data && limits.data.max_skills != null && (
|
||||||
|
<span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted">
|
||||||
|
{limits.data.current_skills_count} de {limits.data.max_skills} skills
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch id="archived" checked={showArchived} onCheckedChange={setShowArchived} />
|
||||||
|
<Label htmlFor="archived" className="text-sm text-muted-foreground cursor-pointer">
|
||||||
|
Arquivadas
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span tabIndex={atLimit || agentNotReady ? 0 : -1}>
|
||||||
|
<Button
|
||||||
|
asChild={!atLimit && !agentNotReady}
|
||||||
|
disabled={atLimit || agentNotReady}
|
||||||
|
className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||||
|
>
|
||||||
|
{atLimit || agentNotReady ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Nova skill
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link to="/painel/skills/nova">
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Nova skill
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{(atLimit || agentNotReady) && (
|
||||||
|
<TooltipContent>
|
||||||
|
{agentNotReady
|
||||||
|
? "Aguarde o provisionamento do agente terminar"
|
||||||
|
: `Limite de ${limits.data?.max_skills} skills do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{noSub ? (
|
||||||
|
<NoSubscriptionState />
|
||||||
|
) : agentNotReady ? (
|
||||||
|
<AgentProvisioningState />
|
||||||
|
) : skills.data && skills.data.length === 0 ? (
|
||||||
|
<EmptySkillsState />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{skills.data?.map((skill) => (
|
||||||
|
<SkillCard key={skill.id} skill={skill} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/routes/painel.skills.nova.tsx
Normal file
267
src/routes/painel.skills.nova.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, Check, CircleDashed, Loader2, Sparkles, X } from "lucide-react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { skillFormSchema, AVAILABLE_TOOLS, type SkillFormValues } from "@/lib/skill-schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/skills/nova")({
|
||||||
|
component: NovaSkillPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIELDS_META: { key: keyof SkillFormValues; label: string; required: boolean }[] = [
|
||||||
|
{ key: "name", label: "Nome", required: true },
|
||||||
|
{ key: "description", label: "Descrição", required: true },
|
||||||
|
{ key: "trigger_keywords", label: "Gatilhos", required: true },
|
||||||
|
{ key: "expected_inputs", label: "Inputs esperados", required: false },
|
||||||
|
{ key: "steps", label: "Passo a passo", required: true },
|
||||||
|
{ key: "required_tools", label: "Ferramentas", required: true },
|
||||||
|
{ key: "success_criteria", label: "Critério de sucesso", required: true },
|
||||||
|
{ key: "example_use_case", label: "Exemplo de uso", required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function NovaSkillPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<SkillFormValues>({
|
||||||
|
resolver: zodResolver(skillFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
trigger_keywords: "",
|
||||||
|
expected_inputs: "",
|
||||||
|
steps: "",
|
||||||
|
required_tools: [],
|
||||||
|
success_criteria: "",
|
||||||
|
example_use_case: "",
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = form.watch();
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
const fieldFilled = (key: keyof SkillFormValues): boolean => {
|
||||||
|
const v = values[key];
|
||||||
|
if (key === "required_tools") return Array.isArray(v) && v.length > 0;
|
||||||
|
return typeof v === "string" && v.trim().length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
const valid = await form.trigger();
|
||||||
|
if (!valid) return;
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const formValues = form.getValues();
|
||||||
|
const formInputs = {
|
||||||
|
name: formValues.name,
|
||||||
|
description: formValues.description,
|
||||||
|
trigger_keywords: formValues.trigger_keywords,
|
||||||
|
expected_inputs: formValues.expected_inputs || null,
|
||||||
|
steps: formValues.steps,
|
||||||
|
required_tools: formValues.required_tools,
|
||||||
|
success_criteria: formValues.success_criteria,
|
||||||
|
example_use_case: formValues.example_use_case || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.functions.invoke("generate-skill-markdown", {
|
||||||
|
body: { form_inputs: formInputs },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const msg = error.message || "Falha ao gerar skill";
|
||||||
|
if (msg.includes("429") || msg.includes("Muitas")) {
|
||||||
|
toast.error("Muitas requisições. Aguarde 1 minuto e tente novamente.");
|
||||||
|
} else {
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to preview with state
|
||||||
|
navigate({
|
||||||
|
to: "/painel/skills/preview",
|
||||||
|
search: {},
|
||||||
|
state: {
|
||||||
|
markdown_content: data.markdown_content,
|
||||||
|
form_inputs: formInputs,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Erro inesperado ao gerar skill. Tente novamente.");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to="/painel/skills">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Nova skill</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Preencha os campos e gere o conteúdo com IA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Form column */}
|
||||||
|
<div className="lg:col-span-2 space-y-5">
|
||||||
|
<FormField label="Nome" helperText="Nome curto para identificar a skill" error={form.formState.errors.name?.message}>
|
||||||
|
<Input {...form.register("name")} placeholder="Ex: Agendar reunião" maxLength={60} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Descrição" helperText="O que essa skill faz, em uma frase" error={form.formState.errors.description?.message}>
|
||||||
|
<Textarea {...form.register("description")} placeholder="Ex: Cria eventos no Google Calendar a partir de instruções naturais" rows={2} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Gatilhos / palavras-chave" helperText="Quando o Mika deve acionar esta skill" error={form.formState.errors.trigger_keywords?.message}>
|
||||||
|
<Input {...form.register("trigger_keywords")} placeholder="Ex: agendar, marcar reunião, criar evento" maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Inputs esperados" helperText="(Opcional) Que informações o usuário deve fornecer">
|
||||||
|
<Textarea {...form.register("expected_inputs")} placeholder="Ex: data, hora, participantes, assunto" rows={2} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Passo a passo" helperText="Descreva o que o Mika deve fazer (mín. 50 chars)" error={form.formState.errors.steps?.message}>
|
||||||
|
<Textarea
|
||||||
|
{...form.register("steps")}
|
||||||
|
placeholder="1. Extrair data e hora do input do usuário 2. Verificar disponibilidade no Google Calendar 3. Criar o evento com os participantes 4. Enviar confirmação ao usuário"
|
||||||
|
rows={5}
|
||||||
|
maxLength={3000}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Ferramentas necessárias" helperText="Quais serviços o Mika vai precisar" error={form.formState.errors.required_tools?.message}>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="required_tools"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AVAILABLE_TOOLS.map((tool) => {
|
||||||
|
const selected = field.value?.includes(tool);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={tool}
|
||||||
|
variant={selected ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors select-none",
|
||||||
|
selected
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary-dark"
|
||||||
|
: "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const next = selected
|
||||||
|
? field.value.filter((t) => t !== tool)
|
||||||
|
: [...(field.value ?? []), tool];
|
||||||
|
field.onChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Critério de sucesso" helperText="Como saber se a skill foi executada corretamente" error={form.formState.errors.success_criteria?.message}>
|
||||||
|
<Input {...form.register("success_criteria")} placeholder="Ex: evento criado com data e participantes corretos" maxLength={300} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Exemplo de uso" helperText="(Opcional) Uma situação concreta de uso">
|
||||||
|
<Textarea {...form.register("example_use_case")} placeholder='Ex: "Mika, agenda uma reunião com o João amanhã às 14h"' rows={2} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky checklist column */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="lg:sticky lg:top-24 space-y-4">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft">
|
||||||
|
<h3 className="font-semibold text-sm mb-4">Checklist</h3>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{FIELDS_META.map((f) => {
|
||||||
|
const done = fieldFilled(f.key);
|
||||||
|
return (
|
||||||
|
<li key={f.key} className="flex items-center gap-2 text-sm">
|
||||||
|
{done ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CircleDashed className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={done ? "text-foreground" : "text-muted-foreground"}>
|
||||||
|
{f.label}
|
||||||
|
{!f.required && " (opcional)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!isValid || generating}
|
||||||
|
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground h-12 text-base"
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
Gerando sua skill...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-5 w-5 mr-2" />
|
||||||
|
Gerar com IA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
helperText,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
helperText?: string;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="font-medium">{label}</Label>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
|
) : helperText ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/routes/painel.skills.preview.tsx
Normal file
246
src/routes/painel.skills.preview.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute, useNavigate, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState, lazy, Suspense, useCallback, useMemo } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Eye, FileText, Loader2, Play, Save } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
|
||||||
|
|
||||||
|
// Lazy-load CodeMirror to reduce initial bundle
|
||||||
|
const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/skills/preview")({
|
||||||
|
component: SkillPreviewPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SkillPreviewPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const agent = useAgentInstance();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// State passed from /painel/skills/nova
|
||||||
|
const routerState = (router.state.location.state ?? {}) as {
|
||||||
|
markdown_content?: string;
|
||||||
|
form_inputs?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [markdown, setMarkdown] = useState(routerState.markdown_content ?? "");
|
||||||
|
const formInputs = useMemo(() => routerState.form_inputs ?? {}, [routerState.form_inputs]);
|
||||||
|
const [testOpen, setTestOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
|
||||||
|
const agentId = agent.data?.id;
|
||||||
|
|
||||||
|
const hasContent = markdown.trim().length > 0;
|
||||||
|
|
||||||
|
// Helper: parse Supabase error codes
|
||||||
|
const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
|
||||||
|
if (error.code === "P0001") {
|
||||||
|
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
|
||||||
|
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (error.code === "P0002") {
|
||||||
|
toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (error.code === "23505") {
|
||||||
|
toast.error("Você já tem uma skill com esse nome. Escolha outro.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const createSkill = useCallback(async (publish: boolean) => {
|
||||||
|
if (!user || !agentId) return;
|
||||||
|
if (markdown.length > 50000) {
|
||||||
|
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setter = publish ? setPublishing : setSaving;
|
||||||
|
setter(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fi = formInputs as Record<string, unknown>;
|
||||||
|
// 1. Create skill
|
||||||
|
const { data: skill, error: skillErr } = await supabase
|
||||||
|
.from("skills")
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
agent_instance_id: agentId,
|
||||||
|
name: (fi.name as string) || "Skill sem nome",
|
||||||
|
description: (fi.description as string) || "",
|
||||||
|
trigger_keywords: (fi.trigger_keywords as string) || "",
|
||||||
|
status: "draft",
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (skillErr) {
|
||||||
|
if (!handleSupabaseError(skillErr as { code?: string })) {
|
||||||
|
toast.error(skillErr.message || "Erro ao criar skill");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create version
|
||||||
|
const { data: ver, error: verErr } = await supabase
|
||||||
|
.from("skill_versions")
|
||||||
|
.insert([{
|
||||||
|
skill_id: skill.id,
|
||||||
|
version_number: 1,
|
||||||
|
markdown_content: markdown,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form_inputs: formInputs as any,
|
||||||
|
is_live: false,
|
||||||
|
created_by: user.id,
|
||||||
|
}])
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (verErr) {
|
||||||
|
toast.error(verErr.message || "Erro ao salvar versão");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Optionally publish
|
||||||
|
if (publish) {
|
||||||
|
const { data: pubData, error: pubErr } = await supabase.functions.invoke(
|
||||||
|
"publish-skill-version",
|
||||||
|
{ body: { skill_version_id: ver.id } },
|
||||||
|
);
|
||||||
|
if (pubErr) {
|
||||||
|
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
|
||||||
|
} else {
|
||||||
|
toast.success("Skill publicada com sucesso!");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success("Rascunho salvo!");
|
||||||
|
}
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user-limits"] });
|
||||||
|
navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
|
||||||
|
} catch {
|
||||||
|
toast.error("Erro inesperado");
|
||||||
|
} finally {
|
||||||
|
setter(false);
|
||||||
|
}
|
||||||
|
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
|
||||||
|
|
||||||
|
if (!routerState.markdown_content) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-muted-foreground">Nenhum conteúdo para pré-visualizar.</p>
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/painel/skills/nova">Voltar ao formulário</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to="/painel/skills/nova">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-xl font-bold">Pré-visualização</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}>
|
||||||
|
<Play className="h-4 w-4 mr-1" /> Testar antes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => createSkill(false)} disabled={saving || publishing || !hasContent}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||||
|
Salvar rascunho
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createSkill(true)}
|
||||||
|
disabled={saving || publishing || !hasContent}
|
||||||
|
className="bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||||
|
>
|
||||||
|
{publishing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileText className="h-4 w-4 mr-1" />}
|
||||||
|
Publicar agora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Desktop: split | Mobile: tabs */}
|
||||||
|
<div className="hidden lg:grid lg:grid-cols-2 gap-4 min-h-[60vh]">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
<Suspense fallback={<EditorSkeleton />}>
|
||||||
|
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<MarkdownPreview content={markdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Tabs defaultValue="preview">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="editor" className="flex-1">
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> Editor
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview" className="flex-1">
|
||||||
|
<Eye className="h-4 w-4 mr-1" /> Preview
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="editor" className="mt-4 min-h-[50vh]">
|
||||||
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||||
|
<Suspense fallback={<EditorSkeleton />}>
|
||||||
|
<CodeMirrorEditor value={markdown} onChange={setMarkdown} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="preview" className="mt-4">
|
||||||
|
<MarkdownPreview content={markdown} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testOpen && (
|
||||||
|
<SkillTestPanel
|
||||||
|
open={testOpen}
|
||||||
|
onOpenChange={setTestOpen}
|
||||||
|
skillName={(formInputs as Record<string, string>).name || "Skill"}
|
||||||
|
skillVersionId="preview"
|
||||||
|
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
|
||||||
|
stateless={{ markdown_content: markdown }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownPreview({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 overflow-auto max-h-[80vh] prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-60">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/routes/painel.skills.tsx
Normal file
12
src/routes/painel.skills.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/painel/skills")({
|
||||||
|
component: SkillsLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SkillsLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ export const Route = createFileRoute("/painel")({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: "/painel" | "/painel/faturamento" | "/painel/configuracoes";
|
to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/faturamento" | "/painel/configuracoes";
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -73,8 +73,8 @@ interface DisabledNavItem {
|
||||||
|
|
||||||
const NAV: (NavItem | DisabledNavItem)[] = [
|
const NAV: (NavItem | DisabledNavItem)[] = [
|
||||||
{ to: "/painel", label: "Dashboard", icon: Home },
|
{ to: "/painel", label: "Dashboard", icon: Home },
|
||||||
{ to: null, label: "Meu Agente", icon: Bot, disabled: true },
|
{ to: "/painel/agente", label: "Meu Agente", icon: Bot },
|
||||||
{ to: null, label: "Skills", icon: Sparkles, disabled: true },
|
{ to: "/painel/skills", label: "Skills", icon: Sparkles },
|
||||||
{ to: null, label: "Integrações", icon: Plug, disabled: true },
|
{ to: null, label: "Integrações", icon: Plug, disabled: true },
|
||||||
{ to: "/painel/faturamento", label: "Faturamento", icon: CreditCard },
|
{ to: "/painel/faturamento", label: "Faturamento", icon: CreditCard },
|
||||||
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },
|
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue