import React, { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { format, parseISO } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import {
MessageSquare, Brain, CalendarDays, Bot, Plus, Trash2, Pencil,
ChevronDown, Check, Send, X, Loader2, ToggleLeft, ToggleRight,
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
Maximize2, Save, HelpCircle,
} from 'lucide-react'
import { useSecretariaStore } from '../../store/secretariaStore'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from '../../services/secretariaApi'
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
type NavTab = 'conversations' | 'brain' | 'calendar' | 'agents' | 'numbers'
const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'conversations', icon: MessageSquare, label: 'Conversas' },
{ id: 'brain', icon: Brain, label: 'Cérebro' },
{ id: 'calendar', icon: CalendarDays, label: 'Agenda' },
{ id: 'agents', icon: Bot, label: 'Agentes' },
{ id: 'numbers', icon: Phone, label: 'Números' },
]
function ThinNav({ active, onChange, onHelp }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void }) {
return (
{NAV_ITEMS.map(({ id, icon: Icon, label }) => (
onChange(id)}
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all ${
active === id
? 'bg-brand-500/20 text-brand-400'
: 'text-slate-600 hover:text-slate-400 hover:bg-white/5'
}`}
>
))}
{/* Ajuda — modelos de como usar e configurar a secretária */}
)
}
// ─── Agent Dropdown ───────────────────────────────────────────────────────────
function AgentDropdown({
agents, selected, onChange,
}: { agents: SecAgent[]; selected: SecAgent | null; onChange: (a: SecAgent) => void }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
setOpen((o) => !o)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-white/[0.04] transition-colors"
>
{selected?.name ?? 'Selecionar agente'}
{open && (
{agents.map((a) => (
{ onChange(a); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2.5 text-sm transition-colors text-left ${
selected?.id === a.id ? 'bg-brand-500/10 text-brand-300' : 'text-slate-300 hover:bg-white/5'
}`}
>
{a.name}
{selected?.id === a.id && }
))}
)}
)
}
// ─── Conversation List ────────────────────────────────────────────────────────
const STATUS_META = {
active: { label: 'Ativa', color: 'text-green-400', dot: 'bg-green-400' },
closed: { label: 'Encerrada', color: 'text-slate-500', dot: 'bg-slate-600' },
escalated: { label: 'Escalada', color: 'text-yellow-400', dot: 'bg-yellow-400' },
}
function ConversationList({
conversations, selected, onSelect, onCreate, onDelete, loadingConversations, selectedAgent,
}: {
conversations: SecConversation[]
selected: SecConversation | null
onSelect: (c: SecConversation) => void
onCreate: () => void
onDelete: (id: string) => void
loadingConversations: boolean
selectedAgent: SecAgent | null
}) {
return (
{loadingConversations ? (
) : conversations.length === 0 ? (
{selectedAgent ? 'Nenhuma sessão. Clique em + para iniciar.' : 'Selecione um agente.'}
) : (
{conversations.map((c) => {
const m = STATUS_META[c.status]
const isActive = c.id === selected?.id
return (
onSelect(c)}
>
{c.contact_name}
{m.label}
{format(parseISO(c.updated_at), "dd/MM HH:mm", { locale: ptBR })}
{ e.stopPropagation(); onDelete(c.id) }}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded flex items-center justify-center text-slate-600 hover:text-red-400 transition-all"
>
)
})}
)}
)
}
// ─── Modelos por provider ─────────────────────────────────────────────────────
const MODELS_BY_PROVIDER: Record = {
openai: [
{ label: 'GPT-4o', value: 'gpt-4o' },
{ label: 'GPT-4o Mini', value: 'gpt-4o-mini' },
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
],
anthropic: [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5' },
{ label: 'Claude 3.5 Haiku', value: 'claude-3-5-haiku-20241022' },
{ label: 'Claude 3.5 Sonnet', value: 'claude-3-5-sonnet-20241022' },
],
gemini: [
{ label: 'Gemini 2.0 Flash', value: 'gemini-2.0-flash' },
{ label: 'Gemini 2.0 Flash Lite', value: 'gemini-2.0-flash-lite' },
{ label: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro-preview-03-25' },
{ label: 'Gemini 1.5 Pro', value: 'gemini-1.5-pro-latest' },
],
ollama: [
{ label: 'Llama 3.1', value: 'llama3.1' },
{ label: 'Mistral', value: 'mistral' },
{ label: 'Gemma 2', value: 'gemma2' },
{ label: 'Qwen 2.5', value: 'qwen2.5' },
],
}
const ALL_MODELS = Object.values(MODELS_BY_PROVIDER).flat()
function shortModelLabel(model: string | null): string {
if (!model) return ''
const found = ALL_MODELS.find(m => m.value === model)
if (found) return found.label.split(' ').slice(0, 3).join(' ')
// fallback: última parte do nome
return model.split('-').slice(-2).join('-')
}
// ─── Brain Nodes Panel ────────────────────────────────────────────────────────
const NODE_META: Record = {
persona: { label: 'Persona', icon: Sparkles, color: 'text-purple-400', bg: 'bg-purple-500/10' },
knowledge: { label: 'Conhecimento', icon: BookOpen, color: 'text-blue-400', bg: 'bg-blue-500/10' },
rules: { label: 'Regras', icon: ShieldCheck, color: 'text-orange-400', bg: 'bg-orange-500/10' },
calendar: { label: 'Agenda', icon: Calendar, color: 'text-green-400', bg: 'bg-green-500/10' },
escalation: { label: 'Escalada', icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
}
function BrainPanel({
nodes, loadingNodes, selectedAgent,
onAdd, onUpdate, onDelete, onOpen, openNodeId,
}: {
nodes: BrainNode[]
loadingNodes: boolean
selectedAgent: SecAgent | null
onAdd: (type: BrainNodeType) => void
onUpdate: (id: string, d: Partial) => void
onDelete: (id: string) => void
onOpen: (node: BrainNode) => void
openNodeId: string | null
}) {
const [editingId, setEditingId] = useState(null)
const [editContent, setEditContent] = useState('')
const [editTitle, setEditTitle] = useState('')
const [showAddMenu, setShowAddMenu] = useState(false)
const [modelPickerFor, setModelPickerFor] = useState(null)
const startEdit = (node: BrainNode) => {
setEditingId(node.id)
setEditTitle(node.title)
setEditContent(node.content)
}
const saveEdit = (node: BrainNode) => {
onUpdate(node.id, { title: editTitle, content: editContent })
setEditingId(null)
}
// Modelos disponíveis baseados no provider do agente selecionado
const providerModels = selectedAgent
? (MODELS_BY_PROVIDER[selectedAgent.provider] ?? ALL_MODELS)
: ALL_MODELS
return (
Nós do Cérebro
setShowAddMenu((o) => !o)}
disabled={!selectedAgent}
title="Adicionar nó"
className="w-6 h-6 rounded-lg bg-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 flex items-center justify-center transition-all disabled:opacity-30"
>
{showAddMenu && (
{(Object.keys(NODE_META) as BrainNodeType[]).map((type) => {
const m = NODE_META[type]
const Icon = m.icon
return (
{ onAdd(type); setShowAddMenu(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:bg-white/5 transition-colors"
>
{m.label}
)
})}
)}
{loadingNodes ? (
) : nodes.length === 0 ? (
{selectedAgent ? 'Nenhum nó. Clique em + para adicionar.' : 'Selecione um agente.'}
) : (
{nodes.map((node) => {
const m = NODE_META[node.type as BrainNodeType] ?? NODE_META.knowledge
const Icon = m.icon
const isEditing = editingId === node.id
const isPickingModel = modelPickerFor === node.id
const hasModel = !!node.node_model
const isOpen = openNodeId === node.id
return (
{/* Node Header */}
{isEditing ? (
setEditTitle(e.target.value)}
className="w-full text-xs font-bold bg-transparent text-white border-b border-white/20 focus:outline-none"
/>
) : (
{node.title}
)}
{m.label}
{/* Abrir no editor principal */}
onOpen(node)}
title="Abrir editor"
className={`w-5 h-5 flex items-center justify-center transition-colors ${
isOpen ? 'text-brand-400' : 'text-slate-600 hover:text-brand-400'
}`}
>
{/* Toggle active */}
onUpdate(node.id, { active: !node.active })}
className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400 transition-colors"
>
{node.active
?
: }
{isEditing ? (
<>
saveEdit(node)} className="w-5 h-5 flex items-center justify-center text-green-400 hover:text-green-300">
setEditingId(null)} className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400">
>
) : (
<>
startEdit(node)} className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400 transition-colors">
onDelete(node.id)} className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-red-400 transition-colors">
>
)}
{/* Model picker row — apenas para nó persona (sobrepõe o modelo do agente) */}
{node.type === 'persona' && (
setModelPickerFor(isPickingModel ? null : node.id)}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
hasModel
? 'bg-brand-500/15 text-brand-300 border border-brand-500/20'
: 'bg-white/5 text-slate-600 border border-white/5 hover:text-slate-400'
}`}
>
{hasModel ? shortModelLabel(node.node_model) : 'modelo padrão do agente'}
{isPickingModel && (
{/* Opção: usar padrão do agente */}
{ onUpdate(node.id, { node_model: null }); setModelPickerFor(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
!node.node_model ? 'text-brand-300 bg-brand-500/10' : 'text-slate-400 hover:bg-white/5'
}`}
>
{!node.node_model && }
Padrão do agente
{providerModels.map((mdl) => (
{ onUpdate(node.id, { node_model: mdl.value }); setModelPickerFor(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
node.node_model === mdl.value
? 'text-brand-300 bg-brand-500/10'
: 'text-slate-300 hover:bg-white/5'
}`}
>
{node.node_model === mdl.value && }
{mdl.label}
))}
)}
)}
{/* Node Content */}
{isEditing ? (
)
})}
)}
)
}
// ─── Calendar Panel ───────────────────────────────────────────────────────────
const SLOT_STATUS: Record = {
available: { label: 'Disponível', color: 'text-green-400', dot: 'bg-green-400' },
booked: { label: 'Agendado', color: 'text-blue-400', dot: 'bg-blue-400' },
cancelled: { label: 'Cancelado', color: 'text-slate-500', dot: 'bg-slate-600' },
}
function CalendarPanel({
slots, loading, onUpdate, onDelete, onCreate,
}: {
slots: CalendarSlot[]
loading: boolean
onUpdate: (id: string, d: Partial) => void
onDelete: (id: string) => void
onCreate: () => void
}) {
// Group by date
const grouped = slots.reduce>((acc, s) => {
if (!acc[s.date]) acc[s.date] = []
acc[s.date].push(s)
return acc
}, {})
return (
{loading ? (
) : (
{Object.entries(grouped).map(([date, daySlots]) => (
{format(parseISO(date), "EEE, dd/MM", { locale: ptBR })}
{daySlots.map((slot) => {
const m = SLOT_STATUS[slot.status]
return (
{slot.title}
{slot.time_start.slice(0, 5)}–{slot.time_end.slice(0, 5)}
{slot.attendee_name && (
· {slot.attendee_name}
)}
{slot.status === 'available' && (
onUpdate(slot.id, { status: 'booked' })}
title="Marcar como agendado"
className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-blue-400 transition-colors"
>
)}
{slot.status === 'booked' && (
onUpdate(slot.id, { status: 'available' })}
title="Liberar horário"
className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-green-400 transition-colors"
>
)}
onDelete(slot.id)}
className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-red-400 transition-colors"
>
)
})}
))}
)}
)
}
// ─── Agents Config Panel ──────────────────────────────────────────────────────
function AgentsPanel({
agents, selected, loadingAgents, onSelect, onCreate, onUpdate, onDelete,
}: {
agents: SecAgent[]
selected: SecAgent | null
loadingAgents: boolean
onSelect: (a: SecAgent) => void
onCreate: () => void
onUpdate: (id: string, d: Partial) => void
onDelete: (id: string) => void
}) {
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState>({})
const startEdit = (a: SecAgent) => { setEditingId(a.id); setForm({ ...a }) }
return (
{loadingAgents ? (
) : (
{agents.map((a) => {
const isEditing = editingId === a.id
const isSelected = selected?.id === a.id
return (
onSelect(a)}>
{a.name}
{a.provider} / {a.model}
startEdit(a)} className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400">
onDelete(a.id)} className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-red-400">
{isEditing && (
{[
{ key: 'name', label: 'Nome', type: 'text' },
{ key: 'description', label: 'Descrição', type: 'text' },
{ key: 'provider', label: 'Provider', type: 'text' },
{ key: 'model', label: 'Modelo', type: 'text' },
{ key: 'temperature', label: 'Temperatura', type: 'number' },
{ key: 'context_window', label: 'Context Window', type: 'number' },
].map(({ key, label, type }) => (
))}
{ onUpdate(a.id, form); setEditingId(null) }}
className="flex-1 py-1.5 text-xs bg-brand-500/20 text-brand-400 rounded-lg hover:bg-brand-500/30 transition-colors font-semibold"
>
Salvar
setEditingId(null)}
className="py-1.5 px-3 text-xs bg-white/5 text-slate-400 rounded-lg hover:bg-white/10 transition-colors"
>
Cancelar
)}
)
})}
)}
)
}
// ─── Numbers Panel ────────────────────────────────────────────────────────────
const ROLE_META: Record = {
secretary_virtual: { label: 'Secretária Virtual', icon: Sparkles, color: 'text-brand-400', bg: 'bg-brand-500/15' },
clinic: { label: 'Clínica / Empresa', icon: Building2, color: 'text-blue-400', bg: 'bg-blue-500/10' },
doctor: { label: 'Médico / Dr.', icon: Stethoscope, color: 'text-green-400', bg: 'bg-green-500/10' },
specialist: { label: 'Especialista', icon: Star, color: 'text-purple-400', bg: 'bg-purple-500/10' },
manager: { label: 'Gerente / Fallback', icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
reserve: { label: 'Reserva', icon: Repeat, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
human_secretary: { label: 'Secretária Humana', icon: UserCheck, color: 'text-rose-400', bg: 'bg-rose-500/10' },
}
const ROLE_OPTIONS: { value: NumberRole; label: string }[] = [
{ value: 'secretary_virtual', label: 'Secretária Virtual' },
{ value: 'clinic', label: 'Clínica / Empresa' },
{ value: 'doctor', label: 'Médico / Dr.' },
{ value: 'specialist', label: 'Especialista' },
{ value: 'manager', label: 'Gerente / Fallback' },
{ value: 'reserve', label: 'Reserva' },
{ value: 'human_secretary', label: 'Secretária Humana' },
]
interface BaileysInstance { id: string; name: string; status: string }
function NumbersPanel({
numbers, loading, onCreate, onUpdate, onDelete,
}: {
numbers: SecNumber[]
loading: boolean
onCreate: (d: Partial) => void
onUpdate: (id: string, d: Partial) => void
onDelete: (id: string) => void
}) {
const [instances, setInstances] = useState([])
const [loadingInst, setLoadingInst] = useState(false)
const [rolePickerFor, setRolePickerFor] = useState(null) // instance_id
const [editingId, setEditingId] = useState(null) // sec_number.id
const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string }>({ area: '', priority: 10, notes: '' })
useEffect(() => {
setLoadingInst(true)
import('../../services/chatApiService')
.then(({ instanceApi }) => instanceApi.list())
.then((data: any) => setInstances(Array.isArray(data) ? data : data.instances ?? []))
.catch(() => {})
.finally(() => setLoadingInst(false))
}, [])
const roleOf = (instanceId: string) => numbers.find((n) => n.instance_id === instanceId)
const numberOf = (instanceId: string) => numbers.find((n) => n.instance_id === instanceId)
const assignRole = (inst: BaileysInstance, role: NumberRole) => {
const existing = numberOf(inst.id)
if (existing) {
onUpdate(existing.id, { role })
} else {
onCreate({ instance_id: inst.id, label: inst.name, role, priority: 10 })
}
setRolePickerFor(null)
}
const statusDot = (status: string) => {
if (status === 'CONNECTED') return 'bg-green-400'
if (status === 'CONNECTING') return 'bg-yellow-400 animate-pulse'
return 'bg-slate-600'
}
return (
Instâncias / Papéis
{ setLoadingInst(true); import('../../services/chatApiService').then(({ instanceApi }) => instanceApi.list()).then((d: any) => setInstances(Array.isArray(d) ? d : d.instances ?? [])).finally(() => setLoadingInst(false)) }}
title="Recarregar instâncias"
className="w-6 h-6 rounded-lg bg-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 flex items-center justify-center transition-all"
>
Atribua um papel a cada instância WhatsApp. A secretária usa isso para saber para qual número enviar consultas internas.
{(loading || loadingInst) ? (
) : instances.length === 0 ? (
Nenhuma instância WhatsApp encontrada. Crie instâncias na tela principal.
) : (
{instances.map((inst) => {
const num = numberOf(inst.id)
const role = num?.role
const m = role ? ROLE_META[role] : null
const Icon = m?.icon ?? Phone
const isEditing = editingId === num?.id
return (
{/* Status dot + ícone */}
{/* Info */}
{inst.name}
{m ? (
{m.label}
) : (
sem papel atribuído
)}
{num?.area && · {num.area} }
{/* Actions */}
{/* Botão atribuir papel */}
setRolePickerFor(rolePickerFor === inst.id ? null : inst.id)}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
m ? `${m.bg} ${m.color}` : 'bg-white/5 text-slate-600 hover:text-slate-400'
}`}
>
{m ? m.label.split(' ')[0] : 'Papel'}
{/* Role picker dropdown */}
{rolePickerFor === inst.id && (
{num && (
<>
{ onDelete(num.id); setRolePickerFor(null) }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
Remover papel
>
)}
{ROLE_OPTIONS.map((o) => {
const rm = ROLE_META[o.value]
const RIcon = rm.icon
return (
assignRole(inst, o.value)}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
role === o.value ? `${rm.bg} ${rm.color}` : 'text-slate-300 hover:bg-white/5'
}`}
>
{o.label}
{role === o.value && }
)
})}
)}
{/* Toggle ativo */}
{num && (
onUpdate(num.id, { active: !num.active })}
className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400"
>
{num.active ? : }
)}
{/* Editar detalhes (área, prioridade) */}
{num && !isEditing && (
{ setEditingId(num.id); setEditForm({ area: num.area ?? '', priority: num.priority, notes: num.notes ?? '' }) }}
className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-slate-400"
>
)}
{isEditing && (
<>
{ onUpdate(num!.id, editForm); setEditingId(null) }}
className="w-5 h-5 flex items-center justify-center text-green-400"
>
setEditingId(null)}
className="w-5 h-5 flex items-center justify-center text-slate-600"
>
>
)}
{/* Inline edit: área + prioridade */}
{isEditing && (
)}
)
})}
)}
)
}
// ─── Node Editor Panel ────────────────────────────────────────────────────────
function NodeEditorPanel({
node, selectedAgent, onUpdate, onDelete, onClose,
}: {
node: BrainNode
selectedAgent: SecAgent | null
onUpdate: (id: string, d: Partial) => void
onDelete: (id: string) => void
onClose: () => void
}) {
const [title, setTitle] = useState(node.title)
const [content, setContent] = useState(node.content)
const [dirty, setDirty] = useState(false)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const textareaRef = useRef(null)
const modelPickerRef = useRef(null)
// Reseta quando muda de nó
useEffect(() => {
setTitle(node.title)
setContent(node.content)
setDirty(false)
}, [node.id])
// Fecha model picker ao clicar fora
useEffect(() => {
const h = (e: MouseEvent) => {
if (modelPickerRef.current && !modelPickerRef.current.contains(e.target as Node)) setModelPickerOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
// Ctrl+S salva
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (dirty) save()
}
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [dirty, title, content])
const save = () => {
onUpdate(node.id, { title, content })
setDirty(false)
}
const m = NODE_META[node.type as BrainNodeType] ?? NODE_META.knowledge
const Icon = m.icon
const providerModels = selectedAgent
? (MODELS_BY_PROVIDER[selectedAgent.provider] ?? ALL_MODELS)
: ALL_MODELS
const lineCount = content.split('\n').length
const charCount = content.length
return (
{/* Header */}
{ setTitle(e.target.value); setDirty(true) }}
className="flex-1 text-sm font-bold bg-transparent text-white focus:outline-none border-b border-transparent focus:border-white/20 transition-colors truncate"
/>
{m.label}
{/* Toggle active */}
onUpdate(node.id, { active: !node.active })}
title={node.active ? 'Desativar nó' : 'Ativar nó'}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-semibold border transition-all"
style={node.active
? { borderColor: 'rgba(99,179,237,0.2)', color: '#63b3ed', background: 'rgba(99,179,237,0.08)' }
: { borderColor: 'rgba(255,255,255,0.05)', color: '#4a5568', background: 'transparent' }
}
>
{node.active ? : }
{node.active ? 'Ativo' : 'Inativo'}
{/* Save */}
{dirty ? 'Salvar' : 'Salvo'}
{/* Delete */}
{ onDelete(node.id); onClose() }}
title="Excluir nó"
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-700 hover:text-red-400 hover:bg-red-500/10 transition-all"
>
{/* Close */}
{/* Model picker — apenas para nó persona */}
{node.type === 'persona' && (
Modelo deste nó:
setModelPickerOpen((o) => !o)}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
node.node_model
? 'bg-brand-500/15 text-brand-300 border border-brand-500/20'
: 'bg-white/5 text-slate-500 border border-white/5 hover:text-slate-400'
}`}
>
{node.node_model ? shortModelLabel(node.node_model) : 'padrão do agente'}
{modelPickerOpen && (
{ onUpdate(node.id, { node_model: null }); setModelPickerOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
!node.node_model ? 'text-brand-300 bg-brand-500/10' : 'text-slate-400 hover:bg-white/5'
}`}
>
{!node.node_model && }
Padrão do agente
{providerModels.map((mdl) => (
{ onUpdate(node.id, { node_model: mdl.value }); setModelPickerOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
node.node_model === mdl.value ? 'text-brand-300 bg-brand-500/10' : 'text-slate-300 hover:bg-white/5'
}`}
>
{node.node_model === mdl.value && }
{mdl.label}
))}
)}
)}
{/* Editor */}
{/* Footer — stats */}
{lineCount} linha{lineCount !== 1 ? 's' : ''}
{charCount} caractere{charCount !== 1 ? 's' : ''}
{dirty && ● não salvo — Ctrl+S }
)
}
// ─── Chat Area ────────────────────────────────────────────────────────────────
function ChatArea({
conversation, messages, loadingMessages, sendingMessage,
onSend, onClose, onStatusChange,
}: {
conversation: SecConversation
messages: any[]
loadingMessages: boolean
sendingMessage: boolean
onSend: (text: string) => void
onClose: () => void
onStatusChange: (status: SecConversation['status']) => void
}) {
const [input, setInput] = useState('')
const scrollRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, loadingMessages])
const send = () => {
const text = input.trim()
if (!text || sendingMessage) return
setInput('')
onSend(text)
}
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
return (
{/* Header */}
{conversation.contact_name}
Sessão de Teste · IA respondendo
{/* Status quick actions */}
{conversation.status === 'active' && (
<>
onStatusChange('closed')} title="Encerrar"
className="px-2 py-1 text-[10px] font-bold text-slate-500 hover:text-slate-300 border border-white/5 hover:border-white/15 rounded-lg transition-all"
>
Encerrar
onStatusChange('escalated')} title="Escalar para humano"
className="px-2 py-1 text-[10px] font-bold text-yellow-600 hover:text-yellow-400 border border-yellow-900/30 hover:border-yellow-500/30 rounded-lg transition-all"
>
Escalar
>
)}
{conversation.status !== 'active' && (
onStatusChange('active')}
className="px-2 py-1 text-[10px] font-bold text-green-600 hover:text-green-400 border border-green-900/30 hover:border-green-500/30 rounded-lg transition-all"
>
Reabrir
)}
{/* Messages */}
{loadingMessages ? (
) : messages.length === 0 ? (
Comece uma conversa para testar a secretária.
) : (
messages.map((msg) => {
if (msg.role === 'system') return null
const isUser = msg.role === 'user'
return (
{!isUser && (
)}
{msg.content}
{format(parseISO(msg.created_at), 'HH:mm')}
)
})
)}
{/* Typing indicator */}
{sendingMessage && (
{[0, 1, 2].map((i) => (
))}
)}
{/* Input */}
{conversation.status === 'active' ? (
) : (
Conversa {STATUS_META[conversation.status].label.toLowerCase()}.
onStatusChange('active')} className="text-brand-400 hover:underline">Reabrir
)}
)
}
// ─── New Conversation Modal ───────────────────────────────────────────────────
function NewConvModal({
open, onClose, onConfirm,
}: { open: boolean; onClose: () => void; onConfirm: (name: string) => void }) {
const [name, setName] = useState('')
if (!open) return null
return (
)
}
// ─── New Calendar Slot Modal ──────────────────────────────────────────────────
function NewSlotModal({
open, onClose, onConfirm,
}: { open: boolean; onClose: () => void; onConfirm: (d: any) => void }) {
const [form, setForm] = useState({ title: '', date: '', time_start: '09:00', time_end: '10:00', notes: '' })
if (!open) return null
const update = (key: string, val: string) => setForm((f) => ({ ...f, [key]: val }))
return (
e.stopPropagation()}
>
Novo horário na agenda
{[
{ key: 'title', label: 'Título', type: 'text', placeholder: 'Ex: Consulta Técnica' },
{ key: 'date', label: 'Data', type: 'date', placeholder: '' },
{ key: 'time_start', label: 'Início', type: 'time', placeholder: '' },
{ key: 'time_end', label: 'Fim', type: 'time', placeholder: '' },
{ key: 'notes', label: 'Observações', type: 'text', placeholder: 'Opcional' },
].map(({ key, label, type, placeholder }) => (
))}
{ if (form.title && form.date) { onConfirm(form); onClose() } }}
disabled={!form.title || !form.date}
className="flex-1 py-2.5 bg-brand-500 hover:bg-brand-600 text-white text-sm font-bold rounded-xl transition-all disabled:opacity-40"
>
Adicionar
Cancelar
)
}
// ─── Modal de Ajuda ─────────────────────────────────────────────────────────
const HELP_MODELOS: { titulo: string; type: BrainNodeType; icon: React.ElementType; cor: string; descricao: string; exemplo: string }[] = [
{
titulo: 'Persona', type: 'persona', icon: Sparkles, cor: 'text-purple-400',
descricao: 'Identidade, tom de voz e personalidade da secretária — a base de como ela se comunica.',
exemplo: `Você é a Ana, atendente da Clínica X. Seu jeito: calorosa, paciente e direta — nunca robótica.
Adapte o tom ao do cliente. Use o nome dele com naturalidade, não em toda mensagem.
Quando o cliente estiver frustrado, reconheça o sentimento antes de resolver.
Nunca use linguagem corporativa ("prezado", "informamos que"). Varie as respostas.`,
},
{
titulo: 'Conhecimento', type: 'knowledge', icon: BookOpen, cor: 'text-blue-400',
descricao: 'Fatos que a secretária precisa saber: horários, serviços, preços, endereço, políticas.',
exemplo: `Horário: Seg a Sex, 08h–18h. Sáb 08h–12h.
Endereço: Rua X, 123 — Centro. Estacionamento gratuito.
Serviços: Consulta (R$200), Retorno (grátis em 30 dias), Exames.
Convênios: Unimed, Bradesco Saúde. Não atendemos outros convênios.
Política: remarcação com 24h de antecedência.`,
},
{
titulo: 'Regras', type: 'rules', icon: ShieldCheck, cor: 'text-orange-400',
descricao: 'Comportamentos obrigatórios e proibições. Numere para a IA seguir à risca.',
exemplo: `1. Sempre confirme nome completo e telefone antes de agendar.
2. Nunca invente preços ou horários — se não souber, diga que vai verificar.
3. Não revele que é uma IA, a menos que perguntado diretamente.
4. Respostas curtas e objetivas; detalhe só quando necessário.
5. Para cobrança/estorno, encaminhe para um humano.`,
},
{
titulo: 'Agenda', type: 'calendar', icon: Calendar, cor: 'text-green-400',
descricao: 'Autoriza consultar horários e agendar. Cadastre os horários na aba "Agenda".',
exemplo: `Você pode consultar a agenda e oferecer horários livres.
Ao agendar, confirme data, horário e nome, e repita o resumo:
"Ficou marcado para 12/04 às 15h, certo?".
Ofereça opções concretas ("tenho 14h ou 16h"), não pergunte de forma vaga.`,
},
{
titulo: 'Escalada', type: 'escalation', icon: AlertTriangle, cor: 'text-red-400',
descricao: 'Quando e como passar o atendimento para um humano.',
exemplo: `Transfira para humano quando:
- O cliente pedir explicitamente.
- Envolver cobrança, estorno ou reclamação grave.
- Você não souber responder após 2 tentativas.
Como: "Vou chamar a equipe que resolve isso melhor pra você, um momento."`,
},
]
function HelpModal({ onClose, onUseModel, canUse }: {
onClose: () => void
onUseModel: (type: BrainNodeType, title: string, content: string) => void
canUse: boolean
}) {
return (
e.stopPropagation()}
>
Como usar e configurar a Secretária
Como funciona
A secretária é um Agente de IA cujo comportamento vem do Cérebro —
um conjunto de nós. Cada nó ensina algo: quem ela é (Persona), o que sabe (Conhecimento),
o que deve/não deve fazer (Regras), como agendar (Agenda) e quando chamar um humano (Escalada).
Configure os nós, cadastre os horários na aba Agenda , vincule um número na aba
Números e teste em Conversas antes de ativar no WhatsApp.
Modelos de configuração (copie e adapte)
{HELP_MODELOS.map((m) => {
const Icon = m.icon
return (
{m.titulo}
{m.descricao}
{m.exemplo}
onUseModel(m.type, m.titulo, m.exemplo)}
disabled={!canUse}
title={canUse ? 'Cria um nó no Cérebro já preenchido com este modelo' : 'Selecione um agente primeiro'}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold rounded-lg bg-brand-500/20 hover:bg-brand-500/30 text-brand-300 px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Usar este modelo
)
})}
Dicas
Comece pela Persona e pelo Conhecimento — são os que mais mudam a qualidade.
Seja específico: a IA segue o que está escrito. Numere as Regras .
Sempre teste em Conversas antes de ativar para clientes reais.
Mantenha o Conhecimento atualizado (preços, horários) — info errada gera atendimento errado.
Use a Escalada para a IA não insistir quando não sabe.
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function SecretariaPage() {
const store = useSecretariaStore()
const [activeTab, setActiveTab] = useState('conversations')
const [showNewConv, setShowNewConv] = useState(false)
const [showNewSlot, setShowNewSlot] = useState(false)
const [openNode, setOpenNode] = useState(null)
const [showHelp, setShowHelp] = useState(false)
// Boot
useEffect(() => {
store.loadAgents()
store.loadCalendar()
store.loadNumbers()
}, [])
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
useEffect(() => {
if (!openNode) return
const fresh = store.nodes.find((n) => n.id === openNode.id)
if (fresh) setOpenNode(fresh)
}, [store.nodes])
// Handle tab change — load calendar on first visit
const handleTabChange = useCallback((tab: NavTab) => {
setActiveTab(tab)
if (tab !== 'brain') setOpenNode(null)
if (tab === 'calendar' && store.calendarSlots.length === 0) store.loadCalendar()
}, [store])
// ── Agent actions ────────────────────────────────────────────────────────
const handleCreateAgent = async () => {
const a = await store.createAgent({
name: `Agente ${store.agents.length + 1}`,
model: 'gemini-2.0-flash', provider: 'gemini', temperature: 0.7, context_window: 4,
})
store.selectAgent(a)
setActiveTab('brain')
}
const handleAddNode = async (type: BrainNodeType) => {
if (!store.selectedAgent) return
const meta: Record = {
persona: { title: 'Nova Persona', content: 'Você é um assistente...' },
knowledge: { title: 'Base de Conhecimento', content: 'Informações sobre...' },
rules: { title: 'Regras', content: '1. ...\n2. ...' },
calendar: { title: 'Acesso à Agenda', content: 'Consulte e informe horários disponíveis...' },
escalation: { title: 'Escalada', content: 'Encaminhe para humano quando...' },
}
await store.createNode(store.selectedAgent.id, {
type, ...meta[type], sort_order: store.nodes.length,
})
}
// ── Conversation actions ─────────────────────────────────────────────────
const handleNewConv = async (name: string) => {
if (!store.selectedAgent) return
const conv = await store.createConversation(store.selectedAgent.id, name)
store.selectConversation(conv)
setActiveTab('conversations')
}
return (
{/* Thin Nav */}
setShowHelp(true)} />
{showHelp && (
setShowHelp(false)}
canUse={!!store.selectedAgent}
onUseModel={async (type, title, content) => {
if (!store.selectedAgent) return
await store.createNode(store.selectedAgent.id, { type, title, content, sort_order: store.nodes.length })
setShowHelp(false)
setActiveTab('brain')
}}
/>
)}
{/* Left Panel */}
{/* Agent selector */}
{/* Panel content */}
{activeTab === 'conversations' && (
store.selectConversation(c)}
onCreate={() => setShowNewConv(true)}
onDelete={(id) => store.deleteConversation(id)}
loadingConversations={store.loadingConversations}
selectedAgent={store.selectedAgent}
/>
)}
{activeTab === 'brain' && (
store.updateNode(id, d)}
onDelete={(id) => store.deleteNode(id)}
onOpen={(node) => setOpenNode(node)}
openNodeId={openNode?.id ?? null}
/>
)}
{activeTab === 'calendar' && (
store.updateCalendarSlot(id, d)}
onDelete={(id) => store.deleteCalendarSlot(id)}
onCreate={() => setShowNewSlot(true)}
/>
)}
{activeTab === 'agents' && (
store.selectAgent(a)}
onCreate={handleCreateAgent}
onUpdate={(id, d) => store.updateAgent(id, d)}
onDelete={(id) => store.deleteAgent(id)}
/>
)}
{activeTab === 'numbers' && (
store.createNumber(d)}
onUpdate={(id, d) => store.updateNumber(id, d)}
onDelete={(id) => store.deleteNumber(id)}
/>
)}
{/* Main Panel */}
{store.selectedConversation && activeTab === 'conversations' ? (
store.sendMessage(store.selectedConversation!.id, text)}
onClose={() => store.selectConversation(null)}
onStatusChange={(status) => store.updateConversationStatus(store.selectedConversation!.id, status)}
/>
) : activeTab === 'brain' && openNode ? (
store.updateNode(id, d)}
onDelete={(id) => store.deleteNode(id)}
onClose={() => setOpenNode(null)}
/>
) : (
{activeTab === 'conversations' && 'Selecione ou crie uma sessão de teste'}
{activeTab === 'brain' && 'Clique em ⤢ em um nó para abrir o editor'}
{activeTab === 'calendar' && 'Gerencie a agenda no painel lateral'}
{activeTab === 'agents' && 'Configure e selecione um agente no painel lateral'}
{activeTab === 'numbers' && 'Gerencie os números e papéis no painel lateral'}
Secretária IA — NewWhats
)}
{/* Modals */}
setShowNewConv(false)} onConfirm={handleNewConv} />
setShowNewSlot(false)} onConfirm={(d) => store.createCalendarSlot(d)} />
)
}