feat(operacao-visivel): F2(ii) sheet de proteção anti-ban + F3 aba de propostas do flywheel

Conexões ganham 'Proteção de envio' (AntiBanSheet sobre GET/PUT /api/v1/ai/pacing
do backend; campo vazio = default do engine) e a tela do agente ganha a aba
Propostas (aplicar = gate humano via publish-por-ponteiro). apiClient ganha put().
Provas Playwright localhost + SQL em .superpowers/evidence/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-07-19 21:05:35 -03:00
co-authored by Claude Fable 5
parent 5a3228ad2d
commit 551ca1f1e6
8 changed files with 489 additions and 7 deletions
+2 -2
View File
@@ -20,9 +20,9 @@
| Feature | Status | Prova |
|---|---|---|
| F2(i) motivo da retenção na conversa | ✅ local | endpoint GET `/api/v1/conversations/[id]/retention` + `lib/inbox/retention-copy.ts` (código→pt-br leigo) + `RetentionNotice` acima do composer. Typecheck+lint zero no diff. Playwright localhost: `.superpowers/evidence/operacao-visivel-f2i-localhost.png` (veto `outside_window` seedado → "Fora da janela de envio (7h–22h, sem domingo)…"). Falta: prova VPS. |
| F2(ii) knobs | ⏳ | contrato despachado ao Terminal B (GET/PATCH `/api/v1/channel-sessions/[id]/anti-ban`) |
| F2(ii) knobs | ✅ local | backend do Terminal B (GET/PUT `/api/v1/ai/pacing`, branch `epic/op-visivel-backend` mergeada); UI = botão "Proteção de envio" no card de Conexões → `AntiBanSheet` (janela, domingo, ritmo+jitter em segundos, teto diário, fuso, escada de warm-up explicada; campo vazio = default do engine). Playwright localhost: salvou janela 8–21 → SQL `channel_knobs` = `8|21`. Evidência `operacao-visivel-f2ii-localhost.png`. Falta: prova VPS. |
| F1 central de avisos | ✅ local | GET `/api/v1/ai/inbox` (+open_count) e PATCH `/api/v1/ai/inbox/[id]` (agent+, audit `ai.inbox_item_updated`); página `/app/ai/inbox` (abas Abertos/Resolvidos, badge severidade, marcar resolvido/reabrir); sino `AlertsBell` no TopBar com contador. Playwright localhost: `.superpowers/evidence/operacao-visivel-f1-localhost.png` (3 avisos seedados → resolve 1 → sino 3→2 ao vivo). Falta: prova VPS. |
| F3 propostas flywheel | ⏳ | contrato despachado ao Terminal B (GET proposals + POST apply) |
| F3 propostas flywheel | ✅ local | backend do B (GET `/api/v1/ai/agents/[id]/proposals` + POST apply, migration 0053 aplicada no remoto via Management API); UI = aba "Propostas" no AgentTabs (`ProposalsPanel`: badge pendente/aplicada, botão admin "Aplicar como versão nova"). Playwright localhost: apply → versão 4 published, ponteiro movido, proposta com applied_version_id (SQL provado). Caminho de veto TESTADO: sessão offline → 422 `channel_session_offline`, proposta segue pendente. Evidência `operacao-visivel-f3-localhost.png`. Falta: prova VPS. |
## Decisões e problemas
@@ -8,6 +8,7 @@ import { AgentForm, type ChannelSessionLite } from "./AgentForm";
import { TestPanel } from "./TestPanel";
import { RunsTable } from "./RunsTable";
import { VersionHistory } from "./VersionHistory";
import { ProposalsPanel } from "./ProposalsPanel";
import type { AgentRow } from "@/hooks/ai/useAgent";
import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
import type { CredentialRow } from "@/hooks/ai/useCredentials";
@@ -23,9 +24,9 @@ interface Props {
}
export function AgentTabs(props: Props) {
const [tab, setTab] = React.useState<"configuration" | "test" | "runs" | "history">(
"configuration",
);
const [tab, setTab] = React.useState<
"configuration" | "test" | "runs" | "history" | "proposals"
>("configuration");
const hasVersion = !!(props.draft || props.published);
return (
@@ -41,6 +42,7 @@ export function AgentTabs(props: Props) {
</TabsTrigger>
<TabsTrigger value="runs">Execuções</TabsTrigger>
<TabsTrigger value="history">Histórico</TabsTrigger>
<TabsTrigger value="proposals">Propostas</TabsTrigger>
</TabsList>
<TabsContent value="configuration" className="m-0">
@@ -68,6 +70,14 @@ export function AgentTabs(props: Props) {
<RunsTable agentId={props.agent.id} active={tab === "runs"} />
</TabsContent>
<TabsContent value="proposals" className="m-0">
<ProposalsPanel
agentId={props.agent.id}
active={tab === "proposals"}
readOnly={props.readOnly}
/>
</TabsContent>
<TabsContent value="history" className="m-0">
<VersionHistory
agentId={props.agent.id}
@@ -0,0 +1,101 @@
"use client";
/**
* Aba "Propostas" do agente (Operação Visível F3): melhorias que o flywheel
* destilou das conversas reais. NADA se aplica sozinho — o botão é o gate
* humano; aplicar cria uma versão NOVA do agente (publish-por-ponteiro).
*/
import { formatDistanceToNowStrict } from "date-fns";
import { ptBR } from "date-fns/locale";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgentProposals, useApplyProposal, type ProposalRow } from "@/hooks/ai/useAgentProposals";
import { ApiError } from "@/lib/api/types";
import { Brain } from "@/lib/ui/icons";
const TYPE_LABEL: Record<ProposalRow["type"], string> = {
playbook_bullet: "Regra de playbook",
golden_case: "Caso exemplar",
reentry_trigger: "Gatilho de reengajamento",
};
export function ProposalsPanel({
agentId,
active,
readOnly,
}: {
agentId: string;
active: boolean;
readOnly?: boolean;
}) {
const { data, isLoading } = useAgentProposals(agentId, active);
const apply = useApplyProposal(agentId);
const handleApply = async (p: ProposalRow) => {
try {
await apply.mutateAsync(p.id);
toast.success("Proposta aplicada como versão nova do agente.");
} catch (err) {
toast.error(err instanceof ApiError ? err.message : "Não foi possível aplicar a proposta.");
}
};
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
);
}
const items = data?.items ?? [];
if (items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 rounded-lg border border-border py-16 text-center">
<Brain size={28} className="text-muted-foreground/60" aria-hidden />
<p className="text-sm font-medium">Nenhuma proposta ainda</p>
<p className="max-w-sm text-xs text-muted-foreground">
O assistente aprende com as conversas reais e propõe melhorias aqui. Você decide o
que entra — nada é aplicado sozinho.
</p>
</div>
);
}
return (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((p) => {
const when = formatDistanceToNowStrict(new Date(p.proposed_at), {
addSuffix: true,
locale: ptBR,
});
return (
<li key={p.id} className="flex items-start gap-3 px-4 py-3" data-testid="proposal-item">
<Badge variant={p.applied_at ? "success" : "info"} className="mt-0.5 shrink-0">
{p.applied_at ? "aplicada" : "pendente"}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">
{TYPE_LABEL[p.type]} · proposta {when}
</p>
<p className="mt-1 whitespace-pre-wrap text-sm">{p.content}</p>
</div>
{!p.applied_at && !readOnly ? (
<Button
size="sm"
variant="outline"
disabled={apply.isPending}
onClick={() => void handleApply(p)}
>
Aplicar como versão nova
</Button>
) : null}
</li>
);
})}
</ul>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
/**
* Proteção de envio (anti-ban) por conexão — Operação Visível F2ii. Edita os
* knobs que o engine JÁ respeita (channel_knobs + teto diário). Modelo mental
* honesto com o motor: campo vazio = padrão conservador do engine (placeholder
* mostra o valor); preenchido = override desta conexão.
*/
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { useUpdatePacingKnobs, type PacingKnobsItem } from "@/hooks/channels/usePacingKnobs";
import { ApiError } from "@/lib/api/types";
interface Props {
item: PacingKnobsItem | null;
canWrite: boolean;
onClose: () => void;
}
/** estado do form: strings cruas dos inputs ('' = usar padrão do motor). */
interface FormState {
window_start_hour: string;
window_end_hour: string;
throttle_s: string;
jitter_s: string;
daily_message_limit: string;
allow_sunday: boolean;
timezone: string;
}
function fromItem(item: PacingKnobsItem): FormState {
const o = item.overrides;
return {
window_start_hour: o?.window_start_hour != null ? String(o.window_start_hour) : "",
window_end_hour: o?.window_end_hour != null ? String(o.window_end_hour) : "",
throttle_s: o?.throttle_ms != null ? String(o.throttle_ms / 1000) : "",
jitter_s: o?.jitter_max_ms != null ? String(o.jitter_max_ms / 1000) : "",
daily_message_limit:
item.channel_session.daily_message_limit != null
? String(item.channel_session.daily_message_limit)
: "",
allow_sunday: o?.allow_sunday ?? item.defaults.allowSunday,
timezone: o?.timezone ?? "",
};
}
const intOrNull = (s: string): number | null => (s.trim() === "" ? null : Math.round(Number(s)));
const msOrNull = (s: string): number | null =>
s.trim() === "" ? null : Math.round(Number(s) * 1000);
export function AntiBanSheet({ item, canWrite, onClose }: Props) {
const update = useUpdatePacingKnobs();
const [form, setForm] = useState<FormState | null>(null);
useEffect(() => {
setForm(item ? fromItem(item) : null);
}, [item]);
const label = useMemo(() => {
if (!item) return "";
const s = item.channel_session;
return s.display_name || s.phone_number || s.waha_session_name || "Conexão";
}, [item]);
if (!item || !form) return null;
const eff = item.effective;
const set = (patch: Partial<FormState>) => setForm((f) => (f ? { ...f, ...patch } : f));
const handleSave = async () => {
try {
await update.mutateAsync({
channel_session_id: item.channel_session.id,
window_start_hour: intOrNull(form.window_start_hour),
window_end_hour: intOrNull(form.window_end_hour),
throttle_ms: msOrNull(form.throttle_s),
jitter_max_ms: msOrNull(form.jitter_s),
allow_sunday: form.allow_sunday,
timezone: form.timezone.trim() === "" ? null : form.timezone.trim(),
...(form.daily_message_limit.trim() !== ""
? { daily_message_limit: Math.round(Number(form.daily_message_limit)) }
: {}),
});
toast.success("Proteção de envio atualizada.");
onClose();
} catch (err) {
toast.error(err instanceof ApiError ? err.message : "Não foi possível salvar.");
}
};
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>Proteção de envio — {label}</SheetTitle>
<SheetDescription>
Estes limites protegem o número contra bloqueio do WhatsApp. Campo vazio usa o
padrão seguro do sistema (mostrado no campo).
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-5 px-4 py-2" data-testid="anti-ban-form">
<fieldset className="flex flex-col gap-2">
<Label>Janela de envio (horário local)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
inputMode="numeric"
placeholder={String(eff.windowStartHour)}
value={form.window_start_hour}
onChange={(e) => set({ window_start_hour: e.target.value })}
disabled={!canWrite}
aria-label="Hora de início da janela"
className="w-20"
/>
<span className="text-sm text-muted-foreground">h até</span>
<Input
type="number"
min={1}
max={24}
inputMode="numeric"
placeholder={String(eff.windowEndHour)}
value={form.window_end_hour}
onChange={(e) => set({ window_end_hour: e.target.value })}
disabled={!canWrite}
aria-label="Hora de fim da janela"
className="w-20"
/>
<span className="text-sm text-muted-foreground">h</span>
</div>
<p className="text-xs text-muted-foreground">
O assistente só envia mensagens dentro desta janela. Fora dela, a resposta fica
agendada para a próxima abertura — você vê o motivo na conversa.
</p>
</fieldset>
<fieldset className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="allow-sunday">Enviar aos domingos</Label>
<p className="text-xs text-muted-foreground">
Desligado por padrão: envio em domingo aumenta o risco de denúncia e bloqueio.
</p>
</div>
<Switch
id="allow-sunday"
checked={form.allow_sunday}
onCheckedChange={(v) => set({ allow_sunday: v })}
disabled={!canWrite}
/>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>Ritmo entre envios (segundos)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
step="0.1"
inputMode="decimal"
placeholder={String(eff.throttleMs / 1000)}
value={form.throttle_s}
onChange={(e) => set({ throttle_s: e.target.value })}
disabled={!canWrite}
aria-label="Intervalo mínimo entre envios em segundos"
className="w-24"
/>
<span className="text-sm text-muted-foreground">+ variação de até</span>
<Input
type="number"
min={0}
step="0.1"
inputMode="decimal"
placeholder={String(eff.jitterMaxMs / 1000)}
value={form.jitter_s}
onChange={(e) => set({ jitter_s: e.target.value })}
disabled={!canWrite}
aria-label="Variação aleatória máxima em segundos"
className="w-24"
/>
<span className="text-sm text-muted-foreground">s</span>
</div>
<p className="text-xs text-muted-foreground">
Intervalo mínimo entre mensagens do mesmo número, mais uma variação aleatória —
ritmo cravado parece robô para o WhatsApp.
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>Teto diário de envios</Label>
<Input
type="number"
min={item.bounds.daily_limit.min}
max={item.bounds.daily_limit.max}
inputMode="numeric"
placeholder="sem teto definido"
value={form.daily_message_limit}
onChange={(e) => set({ daily_message_limit: e.target.value })}
disabled={!canWrite}
aria-label="Teto diário de mensagens"
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Máximo de mensagens que este número envia por dia. Números novos também respeitam
o aquecimento automático abaixo, o que for menor.
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>Fuso horário da janela</Label>
<Input
placeholder={eff.timezone}
value={form.timezone}
onChange={(e) => set({ timezone: e.target.value })}
disabled={!canWrite}
aria-label="Fuso horário IANA"
/>
<p className="text-xs text-muted-foreground">
A janela de envio é avaliada neste fuso (ex.: America/Sao_Paulo).
</p>
</fieldset>
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-xs font-medium">Aquecimento automático de número novo</p>
<p className="mt-1 text-xs text-muted-foreground">
{eff.warmupDailyCaps
.map((s) =>
s.cap === null
? `a partir de ${s.minAgeDays} dias: sem limite de aquecimento`
: `${s.minAgeDays}+ dias: até ${s.cap}/dia`,
)
.join(" · ")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Número recém-conectado envia pouco e sobe aos poucos — enviar demais no início é
a causa nº 1 de bloqueio.
</p>
</div>
</div>
<div className="mt-auto flex justify-end gap-2 border-t border-border px-4 py-3">
<Button variant="ghost" onClick={onClose}>
{canWrite ? "Cancelar" : "Fechar"}
</Button>
{canWrite ? (
<Button onClick={handleSave} disabled={update.isPending} data-testid="anti-ban-save">
{update.isPending ? "Salvando…" : "Salvar proteção"}
</Button>
) : null}
</div>
</SheetContent>
</Sheet>
);
}
@@ -9,6 +9,8 @@ import {
useChannelSessions,
type ChannelSession,
} from "@/hooks/channels/useChannelSessions";
import { usePacingKnobs } from "@/hooks/channels/usePacingKnobs";
import { AntiBanSheet } from "./AntiBanSheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -25,6 +27,7 @@ import {
CircleNotch,
Phone,
Plus,
ShieldCheck,
} from "@/lib/ui/icons";
type Variant = "success" | "warning" | "error" | "neutral";
@@ -56,6 +59,8 @@ export function ConnectionsClient({ wahaConfigured }: { wahaConfigured: boolean
const [creating, setCreating] = useState(false);
const [checking, setChecking] = useState(false);
const [qr, setQr] = useState<{ sessionId: string; title: string } | null>(null);
const [antiBanId, setAntiBanId] = useState<string | null>(null);
const pacingItems = usePacingKnobs().data?.items ?? [];
const invalidate = useCallback(
() => qc.invalidateQueries({ queryKey: ["channel-sessions"] }),
@@ -220,6 +225,10 @@ export function ConnectionsClient({ wahaConfigured }: { wahaConfigured: boolean
)}
Reconectar
</Button>
<Button variant="outline" size="sm" onClick={() => setAntiBanId(c.id)}>
<ShieldCheck size={14} aria-hidden />
Proteção de envio
</Button>
</div>
</Card>
);
@@ -227,6 +236,12 @@ export function ConnectionsClient({ wahaConfigured }: { wahaConfigured: boolean
</div>
)}
<AntiBanSheet
item={pacingItems.find((i) => i.channel_session.id === antiBanId) ?? null}
canWrite
onClose={() => setAntiBanId(null)}
/>
{qr && (
<QrDialog
sessionId={qr.sessionId}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export interface ProposalRow {
id: string;
run_id: string;
dataset: string;
type: "playbook_bullet" | "golden_case" | "reentry_trigger";
target: string;
content: string;
evidence: Record<string, unknown>;
proposed_at: string;
applied_at: string | null;
applied_version_id: string | null;
applied_by: string | null;
}
/** Propostas do flywheel na tela do agente (Operação Visível F3). */
export function useAgentProposals(agentId: string, enabled: boolean) {
return useQuery({
queryKey: ["agent-proposals", agentId],
enabled,
queryFn: () =>
apiClient
.get<{ data: { items: ProposalRow[] } }>(
`/api/v1/ai/agents/${agentId}/proposals?status=all`,
)
.then((r) => r.data),
});
}
export function useApplyProposal(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (proposalId: string) =>
apiClient.post(`/api/v1/ai/agents/${agentId}/proposals/${proposalId}/apply`, {}),
onSettled: () => {
void qc.invalidateQueries({ queryKey: ["agent-proposals", agentId] });
void qc.invalidateQueries({ queryKey: ["agent-versions", agentId] });
},
});
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import type { PacingKnobs } from "@/lib/agent-engine/pacing/defaults";
import type { ChannelKnobsRow, PacingKnobsUpdate } from "@/lib/ai/pacing-knobs";
export interface PacingSessionLite {
id: string;
waha_session_name: string | null;
display_name: string | null;
phone_number: string | null;
status: string | null;
daily_message_limit: number | null;
}
export interface PacingKnobsItem {
channel_session: PacingSessionLite;
effective: PacingKnobs;
overrides: ChannelKnobsRow | null;
defaults: PacingKnobs;
bounds: {
intervalMaxMs: number;
hourLastStart: number;
hourEnd: number;
daily_limit: { min: number; max: number };
};
}
/** Knobs anti-ban por conexão (Operação Visível F2ii) — GET /api/v1/ai/pacing. */
export function usePacingKnobs(enabled = true) {
return useQuery({
queryKey: ["pacing-knobs"],
enabled,
queryFn: () =>
apiClient.get<{ data: { items: PacingKnobsItem[] } }>("/api/v1/ai/pacing").then((r) => r.data),
});
}
export function useUpdatePacingKnobs() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: PacingKnobsUpdate) => apiClient.put("/api/v1/ai/pacing", body),
onSettled: () => qc.invalidateQueries({ queryKey: ["pacing-knobs"] }),
});
}
+5 -2
View File
@@ -3,7 +3,7 @@ import type { ZodSchema } from "zod";
import { ApiError, type ApiErrorBody } from "@/lib/api/types";
import { randomId } from "@/lib/random-id";
type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
export type RequestOpts = {
schema?: ZodSchema<unknown>;
@@ -16,7 +16,7 @@ export type RequestOpts = {
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_ATTEMPTS = 3;
const RETRYABLE_STATUSES = new Set([429, 503]);
const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PATCH", "DELETE"]);
const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PATCH", "PUT", "DELETE"]);
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
@@ -218,6 +218,9 @@ export const apiClient = {
patch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>("PATCH", path, body, opts);
},
put<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>("PUT", path, body, opts);
},
delete<T>(path: string, opts?: RequestOpts): Promise<T> {
return request<T>("DELETE", path, undefined, opts);
},