diff --git a/src/routes/admin.agente.$id.tsx b/src/routes/admin.agente.$id.tsx new file mode 100644 index 0000000..2f268cd --- /dev/null +++ b/src/routes/admin.agente.$id.tsx @@ -0,0 +1,632 @@ +"use client"; + +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + ArrowLeft, + ExternalLink, + Loader2, + Rocket, + Save, + ShieldAlert, + Sparkles, +} from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { invokeFunction } from "@/lib/invoke-function"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export const Route = createFileRoute("/admin/agente/$id")({ + component: AgentDetailPage, +}); + +const MODEL_OPTIONS = [ + { + value: "openrouter/google/gemma-4-27b-a4b-it", + label: "Gemma 4 27B — Rápido e gratuito (Basic/Starter)", + plans: ["basic", "starter"], + }, + { + value: "openrouter/google/gemma-4-31b-it", + label: "Gemma 4 31B — Mais capaz (Professional)", + plans: ["professional", "enterprise"], + }, +]; + +interface AgentDetail { + id: string; + user_id: string; + uuid_tenant: string; + status: string; + telegram_bot_username: string | null; + telegram_user_chat_id: number | null; + railway_service_id: string | null; + vps_pool_id: string | null; + provisioned_at: string | null; + created_at: string; + model_config: Record | null; + vps_pool: { + railway_project_id: string | null; + railway_environment_id: string | null; + } | null; + profile: { + full_name: string | null; + phone: string | null; + onboarding_completed: boolean; + } | null; + user_email: string | null; + subscription: { plans: { slug: string; name: string } | null } | null; +} + +function AgentDetailPage() { + const { id } = Route.useParams(); + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: isAdmin, isLoading: roleLoading } = useQuery({ + queryKey: ["is-admin", user?.id], + enabled: !!user, + queryFn: async () => { + const { data, error } = await supabase.rpc("has_role", { + _user_id: user!.id, + _role: "admin", + }); + if (error) throw error; + return data === true; + }, + }); + + const { data: agent, isLoading: agentLoading } = useQuery({ + queryKey: ["agent-detail", id], + enabled: !!isAdmin, + refetchInterval: 10_000, + queryFn: async () => { + const { data, error } = await supabase + .from("agent_instances") + .select( + `id, user_id, uuid_tenant, status, telegram_bot_username, telegram_user_chat_id, + railway_service_id, vps_pool_id, provisioned_at, created_at, model_config, + vps_pool:vps_pool_id(railway_project_id, railway_environment_id), + profile:profiles!agent_instances_user_id_fkey(full_name, phone, onboarding_completed), + subscription:subscriptions!subscriptions_user_id_fkey(plans(slug, name))`, + ) + .eq("id", id) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + // Normalizar arrays vindos do PostgREST + // deno-lint-ignore no-explicit-any + const d = data as any; + const profile = Array.isArray(d.profile) ? d.profile[0] ?? null : d.profile; + const subscription = Array.isArray(d.subscription) + ? d.subscription.find((s: { plans: unknown }) => s.plans) ?? d.subscription[0] ?? null + : d.subscription; + + // Buscar email via auth (admin) + let userEmail: string | null = null; + try { + const { data: u } = await supabase.auth.admin.getUserById(d.user_id); + userEmail = u?.user?.email ?? null; + } catch { + // Sem permissão admin no client — ignorar + } + + return { ...d, profile, subscription, user_email: userEmail } as AgentDetail; + }, + }); + + const { data: jobs } = useQuery({ + queryKey: ["provisioning-jobs", id], + enabled: !!isAdmin, + refetchInterval: 10_000, + queryFn: async () => { + const { data, error } = await supabase + .from("provisioning_jobs") + .select("id, status, created_at, error_message, attempt") + .eq("agent_instance_id", id) + .order("created_at", { ascending: false }) + .limit(5); + if (error) throw error; + return data; + }, + }); + + // ===== Estado do formulário ===== + const fullName = agent?.profile?.full_name?.trim() || "Usuário"; + const firstName = fullName.split(" ")[0] || "Usuário"; + const planSlug = agent?.subscription?.plans?.slug ?? "basic"; + const isPro = ["professional", "enterprise"].includes(planSlug); + const defaultModel = isPro + ? "openrouter/google/gemma-4-31b-it" + : "openrouter/google/gemma-4-27b-a4b-it"; + + const cfg = (agent?.model_config ?? {}) as Record; + const defaultAgentName = cfg.agent_name || `Mika de ${firstName}`; + const defaultSoul = useMemo( + () => + `Você se chama ${defaultAgentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`, + [defaultAgentName, fullName, firstName], + ); + + const [agentName, setAgentName] = useState(""); + const [soul, setSoul] = useState(""); + const [model, setModel] = useState(""); + const [stt, setStt] = useState("local"); + const [tts, setTts] = useState("disabled"); + const [busy, setBusy] = useState(false); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (!agent || initialized) return; + setAgentName(defaultAgentName); + setSoul(defaultSoul); + setModel(cfg.provider || defaultModel); + setStt(cfg.stt || "local"); + setTts(cfg.tts || "disabled"); + setInitialized(true); + }, [agent, initialized, defaultAgentName, defaultSoul, cfg.provider, cfg.stt, cfg.tts, defaultModel]); + + // ===== Auth guard ===== + useEffect(() => { + if (!authLoading && !user) { + navigate({ to: "/login", search: { redirect: `/admin/agente/${id}` } }); + } + }, [authLoading, user, navigate, id]); + + useEffect(() => { + if (!roleLoading && isAdmin === false) { + navigate({ to: "/painel" }); + } + }, [roleLoading, isAdmin, navigate]); + + if (authLoading || roleLoading || agentLoading) { + return ( +
+ +
+ ); + } + + if (!isAdmin) { + return ( +
+
+
+ +
+

Acesso negado

+ +
+
+ ); + } + + if (!agent) { + return ( +
+
+

Agente não encontrado

+ +
+
+ ); + } + + const needsProvision = !agent.railway_service_id && agent.status !== "active"; + + async function handleProvision() { + if (soul.length < 100) { + toast.error("A personalidade precisa ter pelo menos 100 caracteres."); + return; + } + setBusy(true); + const { data, error } = await invokeFunction<{ success?: boolean; error?: string }>( + "provision-agent", + { + agent_instance_id: id, + agent_name: agentName, + soul_content: soul, + model, + stt_provider: stt, + tts_provider: tts, + }, + ); + setBusy(false); + if (error || data?.error) { + toast.error(`Falha ao provisionar: ${error?.message || data?.error}`); + return; + } + toast.success("Agente provisionado com sucesso! O cliente será notificado."); + queryClient.invalidateQueries({ queryKey: ["agent-detail", id] }); + queryClient.invalidateQueries({ queryKey: ["agents-admin"] }); + queryClient.invalidateQueries({ queryKey: ["provisioning-jobs", id] }); + } + + async function handleUpdate() { + if (soul.length < 100) { + toast.error("A personalidade precisa ter pelo menos 100 caracteres."); + return; + } + setBusy(true); + const { data, error } = await invokeFunction<{ success?: boolean; error?: string }>( + "update-agent-config", + { + agent_instance_id: id, + agent_name: agentName, + soul_content: soul, + model, + stt_provider: stt, + tts_provider: tts, + }, + ); + setBusy(false); + if (error || data?.error) { + toast.error(`Falha ao atualizar: ${error?.message || data?.error}`); + return; + } + toast.success("Configuração atualizada! Redeploy disparado no Railway."); + queryClient.invalidateQueries({ queryKey: ["agent-detail", id] }); + queryClient.invalidateQueries({ queryKey: ["agents-admin"] }); + } + + return ( +
+
+
+
+

+ Configurar agente +

+

+ {agent.uuid_tenant.slice(0, 8)} · {agent.profile?.full_name || "Sem nome"} +

+
+ +
+ +
+ {/* ===== Coluna esquerda — Formulário ===== */} +
+ {/* Cliente */} +
+

Informações do cliente

+
+ + + +
+
Plano
+
+ +
+
+ +
+
Bot Telegram
+
+ {agent.telegram_bot_username ? ( + + @{agent.telegram_bot_username} + + + ) : ( + Não conectado + )} +
+
+
+
+ + {/* Configuração */} +
+

Configuração do agente

+ +
+ + setAgentName(e.target.value.slice(0, 50))} + maxLength={50} + placeholder={`Mika de ${firstName}`} + /> +

{agentName.length}/50

+
+ +
+ +