mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Adicionou configuração de agente
X-Lovable-Edit-ID: edt-6c8bf108-98e1-45b5-b0be-fb7fbf2111a8 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
88228fbaed
8 changed files with 925 additions and 54 deletions
|
|
@ -20,6 +20,7 @@ export type Database = {
|
||||||
created_at: string
|
created_at: string
|
||||||
id: string
|
id: string
|
||||||
last_health_check_at: string | null
|
last_health_check_at: string | null
|
||||||
|
model_config: Json
|
||||||
provisioned_at: string | null
|
provisioned_at: string | null
|
||||||
railway_service_id: string | null
|
railway_service_id: string | null
|
||||||
status: string
|
status: string
|
||||||
|
|
@ -43,6 +44,7 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: string
|
id?: string
|
||||||
last_health_check_at?: string | null
|
last_health_check_at?: string | null
|
||||||
|
model_config?: Json
|
||||||
provisioned_at?: string | null
|
provisioned_at?: string | null
|
||||||
railway_service_id?: string | null
|
railway_service_id?: string | null
|
||||||
status?: string
|
status?: string
|
||||||
|
|
@ -66,6 +68,7 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: string
|
id?: string
|
||||||
last_health_check_at?: string | null
|
last_health_check_at?: string | null
|
||||||
|
model_config?: Json
|
||||||
provisioned_at?: string | null
|
provisioned_at?: string | null
|
||||||
railway_service_id?: string | null
|
railway_service_id?: string | null
|
||||||
status?: string
|
status?: string
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
|
||||||
import { Route as PainelIntegracoesSlugRouteImport } from './routes/painel.integracoes.$slug'
|
import { Route as PainelIntegracoesSlugRouteImport } from './routes/painel.integracoes.$slug'
|
||||||
import { Route as PainelCronjobsNovaRouteImport } from './routes/painel.cronjobs.nova'
|
import { Route as PainelCronjobsNovaRouteImport } from './routes/painel.cronjobs.nova'
|
||||||
import { Route as PainelCronjobsIdRouteImport } from './routes/painel.cronjobs.$id'
|
import { Route as PainelCronjobsIdRouteImport } from './routes/painel.cronjobs.$id'
|
||||||
|
import { Route as AdminAgenteIdRouteImport } from './routes/admin.agente.$id'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SignupRoute = SignupRouteImport.update({
|
||||||
id: '/signup',
|
id: '/signup',
|
||||||
|
|
@ -142,10 +143,15 @@ const PainelCronjobsIdRoute = PainelCronjobsIdRouteImport.update({
|
||||||
path: '/cronjobs/$id',
|
path: '/cronjobs/$id',
|
||||||
getParentRoute: () => PainelRoute,
|
getParentRoute: () => PainelRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminAgenteIdRoute = AdminAgenteIdRouteImport.update({
|
||||||
|
id: '/agente/$id',
|
||||||
|
path: '/agente/$id',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/painel': typeof PainelRouteWithChildren
|
'/painel': typeof PainelRouteWithChildren
|
||||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||||
|
|
@ -157,6 +163,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/admin/agente/$id': typeof AdminAgenteIdRoute
|
||||||
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
||||||
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
||||||
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
|
|
@ -169,7 +176,7 @@ export interface FileRoutesByFullPath {
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||||
|
|
@ -179,6 +186,7 @@ export interface FileRoutesByTo {
|
||||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel': typeof PainelIndexRoute
|
'/painel': typeof PainelIndexRoute
|
||||||
|
'/admin/agente/$id': typeof AdminAgenteIdRoute
|
||||||
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
||||||
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
||||||
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
|
|
@ -192,7 +200,7 @@ export interface FileRoutesByTo {
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/painel': typeof PainelRouteWithChildren
|
'/painel': typeof PainelRouteWithChildren
|
||||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||||
|
|
@ -204,6 +212,7 @@ export interface FileRoutesById {
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/admin/agente/$id': typeof AdminAgenteIdRoute
|
||||||
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
'/painel/cronjobs/$id': typeof PainelCronjobsIdRoute
|
||||||
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
'/painel/cronjobs/nova': typeof PainelCronjobsNovaRoute
|
||||||
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
|
|
@ -230,6 +239,7 @@ export interface FileRouteTypes {
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel/skills'
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/admin/agente/$id'
|
||||||
| '/painel/cronjobs/$id'
|
| '/painel/cronjobs/$id'
|
||||||
| '/painel/cronjobs/nova'
|
| '/painel/cronjobs/nova'
|
||||||
| '/painel/integracoes/$slug'
|
| '/painel/integracoes/$slug'
|
||||||
|
|
@ -252,6 +262,7 @@ export interface FileRouteTypes {
|
||||||
| '/painel/configuracoes'
|
| '/painel/configuracoes'
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel'
|
| '/painel'
|
||||||
|
| '/admin/agente/$id'
|
||||||
| '/painel/cronjobs/$id'
|
| '/painel/cronjobs/$id'
|
||||||
| '/painel/cronjobs/nova'
|
| '/painel/cronjobs/nova'
|
||||||
| '/painel/integracoes/$slug'
|
| '/painel/integracoes/$slug'
|
||||||
|
|
@ -276,6 +287,7 @@ export interface FileRouteTypes {
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel/skills'
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/admin/agente/$id'
|
||||||
| '/painel/cronjobs/$id'
|
| '/painel/cronjobs/$id'
|
||||||
| '/painel/cronjobs/nova'
|
| '/painel/cronjobs/nova'
|
||||||
| '/painel/integracoes/$slug'
|
| '/painel/integracoes/$slug'
|
||||||
|
|
@ -289,7 +301,7 @@ export interface FileRouteTypes {
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AdminRoute: typeof AdminRoute
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
PainelRoute: typeof PainelRouteWithChildren
|
PainelRoute: typeof PainelRouteWithChildren
|
||||||
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
|
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
|
||||||
|
|
@ -454,9 +466,26 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PainelCronjobsIdRouteImport
|
preLoaderRoute: typeof PainelCronjobsIdRouteImport
|
||||||
parentRoute: typeof PainelRoute
|
parentRoute: typeof PainelRoute
|
||||||
}
|
}
|
||||||
|
'/admin/agente/$id': {
|
||||||
|
id: '/admin/agente/$id'
|
||||||
|
path: '/agente/$id'
|
||||||
|
fullPath: '/admin/agente/$id'
|
||||||
|
preLoaderRoute: typeof AdminAgenteIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdminRouteChildren {
|
||||||
|
AdminAgenteIdRoute: typeof AdminAgenteIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
|
AdminAgenteIdRoute: AdminAgenteIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||||
|
|
||||||
interface PainelSkillsRouteChildren {
|
interface PainelSkillsRouteChildren {
|
||||||
PainelSkillsIdRoute: typeof PainelSkillsIdRoute
|
PainelSkillsIdRoute: typeof PainelSkillsIdRoute
|
||||||
PainelSkillsNovaRoute: typeof PainelSkillsNovaRoute
|
PainelSkillsNovaRoute: typeof PainelSkillsNovaRoute
|
||||||
|
|
@ -506,7 +535,7 @@ const PainelRouteWithChildren =
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AdminRoute: AdminRoute,
|
AdminRoute: AdminRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
PainelRoute: PainelRouteWithChildren,
|
PainelRoute: PainelRouteWithChildren,
|
||||||
RecuperarSenhaRoute: RecuperarSenhaRoute,
|
RecuperarSenhaRoute: RecuperarSenhaRoute,
|
||||||
|
|
|
||||||
625
src/routes/admin.agente.$id.tsx
Normal file
625
src/routes/admin.agente.$id.tsx
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
"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<string, unknown> | 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;
|
||||||
|
|
||||||
|
// Email não é acessível via client (RLS) — admin pode ver no Railway/Telegram
|
||||||
|
const userEmail: string | null = null;
|
||||||
|
|
||||||
|
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<string, string | undefined>;
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="max-w-md text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<ShieldAlert className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Acesso negado</h1>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/painel">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao painel
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="max-w-md text-center space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Agente não encontrado</h1>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/admin">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao admin
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-background px-4 sm:px-8 py-8">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Sparkles className="h-7 w-7 text-primary" /> Configurar agente
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground font-mono">
|
||||||
|
{agent.uuid_tenant.slice(0, 8)} · {agent.profile?.full_name || "Sem nome"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link to="/admin">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao admin
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-6">
|
||||||
|
{/* ===== Coluna esquerda — Formulário ===== */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Cliente */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6 shadow-soft space-y-3">
|
||||||
|
<h2 className="font-semibold text-lg">Informações do cliente</h2>
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<Field label="Nome" value={agent.profile?.full_name || "—"} />
|
||||||
|
<Field label="Email" value={agent.user_email || "—"} />
|
||||||
|
<Field label="Telefone" value={agent.profile?.phone || "—"} />
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-muted-foreground mb-1">Plano</dt>
|
||||||
|
<dd>
|
||||||
|
<PlanBadge slug={agent.subscription?.plans?.slug ?? null} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label="Cadastro"
|
||||||
|
value={new Date(agent.created_at).toLocaleDateString("pt-BR")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-muted-foreground mb-1">Bot Telegram</dt>
|
||||||
|
<dd>
|
||||||
|
{agent.telegram_bot_username ? (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${agent.telegram_bot_username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1 font-mono text-sm"
|
||||||
|
>
|
||||||
|
@{agent.telegram_bot_username}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">Não conectado</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Configuração */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6 shadow-soft space-y-4">
|
||||||
|
<h2 className="font-semibold text-lg">Configuração do agente</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-name">Nome do agente</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-name"
|
||||||
|
value={agentName}
|
||||||
|
onChange={(e) => setAgentName(e.target.value.slice(0, 50))}
|
||||||
|
maxLength={50}
|
||||||
|
placeholder={`Mika de ${firstName}`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{agentName.length}/50</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="soul">Personalidade (SOUL.md)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="soul"
|
||||||
|
value={soul}
|
||||||
|
onChange={(e) => setSoul(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className={`text-xs ${soul.length < 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
|
{soul.length} caracteres (mínimo 100)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model">Modelo de IA</Label>
|
||||||
|
<Select value={model} onValueChange={setModel}>
|
||||||
|
<SelectTrigger id="model">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MODEL_OPTIONS.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="stt">STT (transcrição de áudio)</Label>
|
||||||
|
<Select value={stt} onValueChange={setStt}>
|
||||||
|
<SelectTrigger id="stt">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="local">Local — Gratuito (faster-whisper)</SelectItem>
|
||||||
|
<SelectItem value="disabled">Desativado</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tts">TTS (resposta em áudio)</Label>
|
||||||
|
<Select value={tts} onValueChange={setTts}>
|
||||||
|
<SelectTrigger id="tts">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="disabled">Desativado</SelectItem>
|
||||||
|
<SelectItem value="edge">Edge TTS — Gratuito</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Ações */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6 shadow-soft space-y-3">
|
||||||
|
<h2 className="font-semibold text-lg">Ações</h2>
|
||||||
|
{needsProvision ? (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleProvision}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-5 w-5 mr-2" />
|
||||||
|
)}
|
||||||
|
Provisionar agente
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg" className="w-full" disabled={busy} onClick={handleUpdate}>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-5 w-5 mr-2" />
|
||||||
|
)}
|
||||||
|
Salvar e atualizar agente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full">
|
||||||
|
<Link to="/admin">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao admin
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== Coluna direita — Status ===== */}
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<StatusCard agent={agent} />
|
||||||
|
<TelegramCard agent={agent} />
|
||||||
|
<RailwayCard agent={agent} />
|
||||||
|
<HistoryCard jobs={jobs ?? []} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== Subcomponents ==============
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-muted-foreground mb-1">{label}</dt>
|
||||||
|
<dd className="text-sm break-words">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
if (status === "active") return <Badge variant="success">Ativo</Badge>;
|
||||||
|
if (status === "provisioning") return <Badge variant="secondary">Provisionando</Badge>;
|
||||||
|
if (status === "suspended") return <Badge variant="destructive">Suspenso</Badge>;
|
||||||
|
if (status === "error") return <Badge variant="destructive">Erro</Badge>;
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanBadge({ slug }: { slug: string | null }) {
|
||||||
|
if (!slug) return <Badge variant="outline">Sem plano</Badge>;
|
||||||
|
if (slug === "professional" || slug === "enterprise")
|
||||||
|
return <Badge variant="success" className="capitalize">{slug}</Badge>;
|
||||||
|
if (slug === "starter") return <Badge variant="secondary" className="capitalize">{slug}</Badge>;
|
||||||
|
return <Badge variant="outline" className="capitalize">{slug}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ agent }: { agent: AgentDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Status</h3>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Atualiza automaticamente a cada 10s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TelegramCard({ agent }: { agent: AgentDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Telegram</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Bot:</span>{" "}
|
||||||
|
<span className="font-mono">@{agent.telegram_bot_username || "—"}</span>
|
||||||
|
</p>
|
||||||
|
{agent.telegram_user_chat_id && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Chat ID:</span>{" "}
|
||||||
|
<span className="font-mono">{agent.telegram_user_chat_id}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.telegram_bot_username && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full">
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${agent.telegram_bot_username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Testar bot <ExternalLink className="h-3.5 w-3.5 ml-1.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailwayCard({ agent }: { agent: AgentDetail }) {
|
||||||
|
const projectId = agent.vps_pool?.railway_project_id;
|
||||||
|
const serviceId = agent.railway_service_id;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Railway</h3>
|
||||||
|
{serviceId ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Service ID:</span>{" "}
|
||||||
|
<span className="font-mono">{serviceId.slice(0, 8)}…</span>
|
||||||
|
</p>
|
||||||
|
{agent.provisioned_at && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Último deploy:</span>{" "}
|
||||||
|
{new Date(agent.provisioned_at).toLocaleString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{projectId && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full">
|
||||||
|
<a
|
||||||
|
href={`https://railway.com/project/${projectId}/service/${serviceId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Abrir no Railway <ExternalLink className="h-3.5 w-3.5 ml-1.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Não provisionado</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryCard({
|
||||||
|
jobs,
|
||||||
|
}: {
|
||||||
|
jobs: Array<{ id: string; status: string; created_at: string; error_message: string | null; attempt: number }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Histórico</h3>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Sem jobs ainda</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<li key={j.id} className="text-xs space-y-0.5 border-l-2 border-border pl-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
j.status === "completed"
|
||||||
|
? "success"
|
||||||
|
: j.status === "failed"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{j.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{new Date(j.created_at).toLocaleString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{j.error_message && (
|
||||||
|
<p className="text-destructive truncate" title={j.error_message}>
|
||||||
|
{j.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
RotateCw,
|
|
||||||
Server,
|
Server,
|
||||||
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
@ -43,6 +43,8 @@ interface AgentRow {
|
||||||
vps_pool_id: string | null;
|
vps_pool_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
provisioned_at: string | null;
|
provisioned_at: string | null;
|
||||||
|
profile: { full_name: string | null } | null;
|
||||||
|
subscription: { plans: { slug: string; name: string } | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
|
|
@ -65,18 +67,30 @@ function AdminPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: agents, isLoading: agentsLoading } = useQuery({
|
const { data: agents, isLoading: agentsLoading } = useQuery({
|
||||||
queryKey: ["admin-agents"],
|
queryKey: ["agents-admin"],
|
||||||
enabled: !!isAdmin,
|
enabled: !!isAdmin,
|
||||||
|
refetchInterval: 15000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.select(
|
.select(
|
||||||
"id, user_id, status, uuid_tenant, telegram_bot_username, telegram_bot_token_vault_id, railway_service_id, vps_pool_id, created_at, provisioned_at",
|
`id, user_id, status, uuid_tenant, telegram_bot_username, telegram_bot_token_vault_id,
|
||||||
|
railway_service_id, vps_pool_id, created_at, provisioned_at,
|
||||||
|
profile:profiles!agent_instances_user_id_fkey(full_name),
|
||||||
|
subscription:subscriptions!subscriptions_user_id_fkey(plans(slug, name))`,
|
||||||
)
|
)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(100);
|
.limit(100);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data as AgentRow[];
|
// Pegar apenas a subscription ativa (primeira) — o relacionamento retorna array
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
return (data as any[]).map((a) => ({
|
||||||
|
...a,
|
||||||
|
profile: Array.isArray(a.profile) ? a.profile[0] ?? null : a.profile,
|
||||||
|
subscription: Array.isArray(a.subscription)
|
||||||
|
? a.subscription.find((s: { plans: unknown }) => s.plans) ?? a.subscription[0] ?? null
|
||||||
|
: a.subscription,
|
||||||
|
})) as AgentRow[];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,10 +129,7 @@ function AdminPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function action(
|
async function action(fn: "suspend-agent" | "resume-agent", agentId: string) {
|
||||||
fn: "provision-agent" | "suspend-agent" | "resume-agent",
|
|
||||||
agentId: string,
|
|
||||||
) {
|
|
||||||
setBusy(agentId + fn);
|
setBusy(agentId + fn);
|
||||||
const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, {
|
const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, {
|
||||||
agent_instance_id: agentId,
|
agent_instance_id: agentId,
|
||||||
|
|
@ -130,20 +141,20 @@ function AdminPage() {
|
||||||
toast.error(`${fn}: ${data.error}`);
|
toast.error(`${fn}: ${data.error}`);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`${fn} executado com sucesso`);
|
toast.success(`${fn} executado com sucesso`);
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin-agents"] });
|
queryClient.invalidateQueries({ queryKey: ["agents-admin"] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background px-4 sm:px-8 py-8">
|
<div className="min-h-screen bg-background px-4 sm:px-8 py-8">
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<header className="flex items-start justify-between gap-4 flex-wrap">
|
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<Server className="h-7 w-7 text-primary" /> Admin · Mika
|
<Server className="h-7 w-7 text-primary" /> Admin · Mika
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Gerencie agentes provisionados, suspenda e reative containers Railway.
|
Configure e gerencie agentes provisionados.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
|
|
@ -167,7 +178,9 @@ function AdminPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Tenant</TableHead>
|
<TableHead>Tenant</TableHead>
|
||||||
|
<TableHead>Cliente</TableHead>
|
||||||
<TableHead>Bot</TableHead>
|
<TableHead>Bot</TableHead>
|
||||||
|
<TableHead>Plano</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Railway</TableHead>
|
<TableHead>Railway</TableHead>
|
||||||
<TableHead className="text-right">Ações</TableHead>
|
<TableHead className="text-right">Ações</TableHead>
|
||||||
|
|
@ -179,9 +192,15 @@ function AdminPage() {
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{a.uuid_tenant.slice(0, 8)}
|
{a.uuid_tenant.slice(0, 8)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm max-w-[180px] truncate">
|
||||||
|
{a.profile?.full_name || "—"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"}
|
{a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<PlanBadge slug={a.subscription?.plans?.slug ?? null} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={a.status} />
|
<StatusBadge status={a.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -189,34 +208,12 @@ function AdminPage() {
|
||||||
{a.railway_service_id?.slice(0, 8) ?? "—"}
|
{a.railway_service_id?.slice(0, 8) ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right space-x-1 whitespace-nowrap">
|
<TableCell className="text-right space-x-1 whitespace-nowrap">
|
||||||
{(a.status === "provisioning" || a.status === "error") &&
|
<Button asChild size="sm" variant="outline">
|
||||||
!a.railway_service_id && (
|
<Link to="/admin/agente/$id" params={{ id: a.id }}>
|
||||||
<Button
|
<Settings className="h-3.5 w-3.5" />
|
||||||
size="sm"
|
<span className="ml-1.5 hidden sm:inline">Configurar</span>
|
||||||
variant="outline"
|
</Link>
|
||||||
disabled={
|
</Button>
|
||||||
busy === a.id + "provision-agent" ||
|
|
||||||
!a.telegram_bot_token_vault_id
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
!a.telegram_bot_token_vault_id
|
|
||||||
? "Usuário precisa concluir onboarding do Telegram primeiro"
|
|
||||||
: "Provisionar container Railway"
|
|
||||||
}
|
|
||||||
onClick={() => action("provision-agent", a.id)}
|
|
||||||
>
|
|
||||||
{busy === a.id + "provision-agent" ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RotateCw className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1.5">
|
|
||||||
{a.telegram_bot_token_vault_id
|
|
||||||
? "Provisionar"
|
|
||||||
: "Aguardando token"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{a.status === "active" && (
|
{a.status === "active" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -229,7 +226,7 @@ function AdminPage() {
|
||||||
) : (
|
) : (
|
||||||
<PauseCircle className="h-3.5 w-3.5" />
|
<PauseCircle className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
<span className="ml-1.5">Suspender</span>
|
<span className="ml-1.5 hidden sm:inline">Suspender</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{a.status === "suspended" && (
|
{a.status === "suspended" && (
|
||||||
|
|
@ -244,7 +241,7 @@ function AdminPage() {
|
||||||
) : (
|
) : (
|
||||||
<PlayCircle className="h-3.5 w-3.5" />
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
<span className="ml-1.5">Reativar</span>
|
<span className="ml-1.5 hidden sm:inline">Reativar</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -267,3 +264,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||||
if (status === "error") return <Badge variant="destructive">Erro</Badge>;
|
if (status === "error") return <Badge variant="destructive">Erro</Badge>;
|
||||||
return <Badge variant="secondary">{status}</Badge>;
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlanBadge({ slug }: { slug: string | null }) {
|
||||||
|
if (!slug) return <Badge variant="outline" className="text-xs">Sem plano</Badge>;
|
||||||
|
if (slug === "professional" || slug === "enterprise")
|
||||||
|
return <Badge variant="success" className="text-xs capitalize">{slug}</Badge>;
|
||||||
|
if (slug === "starter") return <Badge variant="secondary" className="text-xs capitalize">{slug}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs capitalize">{slug}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,36 @@ export async function deployRailwayService(opts: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert de várias variáveis de uma vez no serviço Railway.
|
||||||
|
* Mais eficiente que múltiplos variableUpsert sequenciais.
|
||||||
|
*/
|
||||||
|
export async function upsertRailwayVariableCollection(opts: {
|
||||||
|
token: string;
|
||||||
|
serviceId: string;
|
||||||
|
environmentId: string;
|
||||||
|
projectId: string;
|
||||||
|
variables: Record<string, string>;
|
||||||
|
replace?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const mutation = `
|
||||||
|
mutation VariableCollectionUpsert($input: VariableCollectionUpsertInput!) {
|
||||||
|
variableCollectionUpsert(input: $input)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const input = {
|
||||||
|
projectId: opts.projectId,
|
||||||
|
environmentId: opts.environmentId,
|
||||||
|
serviceId: opts.serviceId,
|
||||||
|
variables: opts.variables,
|
||||||
|
replace: opts.replace ?? false,
|
||||||
|
};
|
||||||
|
const res = await railwayQuery(mutation, { input }, opts.token);
|
||||||
|
if (res.errors?.length) {
|
||||||
|
throw new Error(`variableCollectionUpsert failed: ${JSON.stringify(res.errors)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert de uma única variável de ambiente no serviço Railway.
|
* Upsert de uma única variável de ambiente no serviço Railway.
|
||||||
* Para "remover" o efeito de uma variável boolean, passe value="" (string vazia).
|
* Para "remover" o efeito de uma variável boolean, passe value="" (string vazia).
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ import {
|
||||||
|
|
||||||
interface RequestBody {
|
interface RequestBody {
|
||||||
agent_instance_id: string;
|
agent_instance_id: string;
|
||||||
|
agent_name?: string;
|
||||||
|
soul_content?: string;
|
||||||
|
model?: string;
|
||||||
|
stt_provider?: string;
|
||||||
|
tts_provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
|
@ -78,7 +83,7 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const fullName = (profile?.full_name?.trim() || "Usuário").toString();
|
const fullName = (profile?.full_name?.trim() || "Usuário").toString();
|
||||||
const firstName = fullName.split(" ")[0] || "Usuário";
|
const firstName = fullName.split(" ")[0] || "Usuário";
|
||||||
const agentName = `Mika de ${firstName}`;
|
const agentName = body.agent_name?.trim() || `Mika de ${firstName}`;
|
||||||
|
|
||||||
// 1c) Carregar subscription ativa (para definir modelo Pro vs Basic)
|
// 1c) Carregar subscription ativa (para definir modelo Pro vs Basic)
|
||||||
const { data: subscription } = await supabase
|
const { data: subscription } = await supabase
|
||||||
|
|
@ -157,7 +162,15 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
// 6) Montar variáveis de ambiente do container
|
// 6) Montar variáveis de ambiente do container
|
||||||
const hasChatId = !!agent.telegram_user_chat_id;
|
const hasChatId = !!agent.telegram_user_chat_id;
|
||||||
const soulContent = `Você se chama ${agentName}. 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.`;
|
const defaultSoul = `Você se chama ${agentName}. 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.`;
|
||||||
|
const soulContent = body.soul_content?.trim() || defaultSoul;
|
||||||
|
|
||||||
|
const defaultModel = isPro
|
||||||
|
? "openrouter/google/gemma-4-31b-it"
|
||||||
|
: "openrouter/google/gemma-4-27b-a4b-it";
|
||||||
|
const model = body.model || defaultModel;
|
||||||
|
const sttProvider = body.stt_provider || "local";
|
||||||
|
const ttsProvider = body.tts_provider || "disabled";
|
||||||
|
|
||||||
const envVars: Record<string, string> = {
|
const envVars: Record<string, string> = {
|
||||||
TELEGRAM_BOT_TOKEN: telegramBotToken,
|
TELEGRAM_BOT_TOKEN: telegramBotToken,
|
||||||
|
|
@ -165,12 +178,11 @@ Deno.serve(async (req) => {
|
||||||
TELEGRAM_HOME_CHANNEL: hasChatId ? String(agent.telegram_user_chat_id) : "",
|
TELEGRAM_HOME_CHANNEL: hasChatId ? String(agent.telegram_user_chat_id) : "",
|
||||||
GATEWAY_ALLOW_ALL_USERS: hasChatId ? "false" : "true",
|
GATEWAY_ALLOW_ALL_USERS: hasChatId ? "false" : "true",
|
||||||
HERMES_SOUL_MD: soulContent,
|
HERMES_SOUL_MD: soulContent,
|
||||||
HERMES_TTS_PROVIDER: "disabled",
|
HERMES_SOUL_OVERRIDE: soulContent,
|
||||||
HERMES_STT_PROVIDER: "local",
|
HERMES_TTS_PROVIDER: ttsProvider,
|
||||||
|
HERMES_STT_PROVIDER: sttProvider,
|
||||||
OPENROUTER_API_KEY,
|
OPENROUTER_API_KEY,
|
||||||
HERMES_MODEL: isPro
|
HERMES_MODEL: model,
|
||||||
? "openrouter/google/gemma-4-31b-it"
|
|
||||||
: "openrouter/google/gemma-4-27b-a4b-it",
|
|
||||||
HERMES_FALLBACK_MODEL: "openrouter/google/gemma-4-31b-it",
|
HERMES_FALLBACK_MODEL: "openrouter/google/gemma-4-31b-it",
|
||||||
API_SERVER_ENABLED: "false",
|
API_SERVER_ENABLED: "false",
|
||||||
HERMES_HOME: "/opt/data",
|
HERMES_HOME: "/opt/data",
|
||||||
|
|
@ -211,7 +223,16 @@ Deno.serve(async (req) => {
|
||||||
// 8) Persistir railway_service_id no agent_instance e no job (status='running')
|
// 8) Persistir railway_service_id no agent_instance e no job (status='running')
|
||||||
await supabase
|
await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.update({ railway_service_id: railwayServiceId, vps_pool_id: pool.id })
|
.update({
|
||||||
|
railway_service_id: railwayServiceId,
|
||||||
|
vps_pool_id: pool.id,
|
||||||
|
model_config: {
|
||||||
|
provider: model,
|
||||||
|
stt: sttProvider,
|
||||||
|
tts: ttsProvider,
|
||||||
|
agent_name: agentName,
|
||||||
|
},
|
||||||
|
})
|
||||||
.eq("id", agent.id);
|
.eq("id", agent.id);
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
|
|
|
||||||
155
supabase/functions/update-agent-config/index.ts
Normal file
155
supabase/functions/update-agent-config/index.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// update-agent-config
|
||||||
|
// Atualiza variáveis de ambiente (SOUL, modelo, STT, TTS) de um agente já provisionado
|
||||||
|
// e dispara redeploy. Apenas admins podem chamar.
|
||||||
|
// verify_jwt = true: precisa de JWT válido + check de role admin.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import {
|
||||||
|
deployRailwayService,
|
||||||
|
getServiceContext,
|
||||||
|
upsertRailwayVariableCollection,
|
||||||
|
} from "../_shared/railway.ts";
|
||||||
|
|
||||||
|
interface RequestBody {
|
||||||
|
agent_instance_id: string;
|
||||||
|
agent_name?: string;
|
||||||
|
soul_content: string;
|
||||||
|
model: string;
|
||||||
|
stt_provider: string;
|
||||||
|
tts_provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||||
|
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||||
|
|
||||||
|
if (!RAILWAY_API_TOKEN) {
|
||||||
|
return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Auth: extrair JWT e validar admin
|
||||||
|
const authHeader = req.headers.get("Authorization") ?? "";
|
||||||
|
const jwt = authHeader.replace(/^Bearer\s+/i, "");
|
||||||
|
if (!jwt) return jsonResponse(401, { error: "missing authorization" });
|
||||||
|
|
||||||
|
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: { headers: { Authorization: `Bearer ${jwt}` } },
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||||
|
if (userErr || !userData?.user) {
|
||||||
|
return jsonResponse(401, { error: "invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", {
|
||||||
|
_user_id: userData.user.id,
|
||||||
|
_role: "admin",
|
||||||
|
});
|
||||||
|
if (roleErr || !isAdmin) {
|
||||||
|
return jsonResponse(403, { error: "admin role required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Validar body
|
||||||
|
let body: RequestBody;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(400, { error: "invalid json body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.agent_instance_id) return jsonResponse(400, { error: "agent_instance_id required" });
|
||||||
|
if (!body.soul_content || body.soul_content.length < 50) {
|
||||||
|
return jsonResponse(400, { error: "soul_content too short (min 50 chars)" });
|
||||||
|
}
|
||||||
|
if (!body.model) return jsonResponse(400, { error: "model required" });
|
||||||
|
|
||||||
|
// 3) Carregar agent + pool
|
||||||
|
const { data: agent, error: agentErr } = await supabase
|
||||||
|
.from("agent_instances")
|
||||||
|
.select("id, railway_service_id, vps_pool_id, vps_pool:vps_pool_id(railway_project_id, railway_environment_id)")
|
||||||
|
.eq("id", body.agent_instance_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (agentErr || !agent) {
|
||||||
|
return jsonResponse(404, { error: "agent_instance not found" });
|
||||||
|
}
|
||||||
|
if (!agent.railway_service_id) {
|
||||||
|
return jsonResponse(409, { error: "agent has no railway_service_id — provision first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const pool = (agent as any).vps_pool;
|
||||||
|
let projectId: string | null = pool?.railway_project_id ?? null;
|
||||||
|
let environmentId: string | null = pool?.railway_environment_id ?? null;
|
||||||
|
|
||||||
|
if (!projectId || !environmentId) {
|
||||||
|
const ctx = await getServiceContext({ token: RAILWAY_API_TOKEN, serviceId: agent.railway_service_id });
|
||||||
|
projectId = projectId ?? ctx.projectId;
|
||||||
|
environmentId = environmentId ?? ctx.environmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectId || !environmentId) {
|
||||||
|
return jsonResponse(500, { error: "failed to resolve railway project/environment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Upsert variáveis
|
||||||
|
const variables: Record<string, string> = {
|
||||||
|
HERMES_SOUL_OVERRIDE: body.soul_content,
|
||||||
|
HERMES_MODEL: body.model,
|
||||||
|
HERMES_FALLBACK_MODEL: "openrouter/google/gemma-4-31b-it",
|
||||||
|
HERMES_STT_PROVIDER: body.stt_provider || "local",
|
||||||
|
HERMES_TTS_PROVIDER: body.tts_provider || "disabled",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upsertRailwayVariableCollection({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
projectId,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deployRailwayService({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("railway update failed:", msg);
|
||||||
|
return jsonResponse(500, { error: "railway update failed", detail: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Persistir model_config
|
||||||
|
await supabase
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({
|
||||||
|
model_config: {
|
||||||
|
provider: body.model,
|
||||||
|
stt: body.stt_provider || "local",
|
||||||
|
tts: body.tts_provider || "disabled",
|
||||||
|
agent_name: body.agent_name ?? null,
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", agent.id);
|
||||||
|
|
||||||
|
return jsonResponse(200, { success: true, redeploying: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.agent_instances
|
||||||
|
ADD COLUMN IF NOT EXISTS model_config jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue