mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Fable 5
parent
5a3228ad2d
commit
551ca1f1e6
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user