feat(frontend): IA > Scorecards, Prompts, Configurações
Três telas novas fecham quase todo o menu IA: Scorecards (critérios ponderados, array dinâmico como no Dialplan), Prompts (templates com versionamento — editar = criar versão + ativar numa ação só, já que não existe endpoint pra listar histórico), e Configurações (providers BYOK + modelos). Chave de API nunca é reexibida em texto puro, nem uma vez — só preview mascarado, mesmo na resposta de criação. Novo componente Textarea em components/ui/input.tsx (mesmo estilo de Input/Select) — primeira tela que precisa de texto longo. Testado ponta a ponta contra a API real: scorecard, template de prompt (v1 -> editar -> v2 ativada), provider BYOK + modelo referenciando ele. Smoke test de regressão nas 16 telas anteriores do tenant + platform, todas 200. Typecheck limpo no monorepo inteiro. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
42
TODO.md
@@ -1054,6 +1054,48 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
|
|||||||
- [ ] Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em
|
- [ ] Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em
|
||||||
breve"
|
breve"
|
||||||
|
|
||||||
|
## PHASE 31 — Frontend: IA > Scorecards, Prompts, Configurações
|
||||||
|
(agente.md secao 96-103, 114-118)
|
||||||
|
- [x] IA > Scorecards (`/app/ia/scorecards`): criar (nome + lista dinâmica
|
||||||
|
de critérios com peso, mesmo padrão de array dinâmico do Dialplan),
|
||||||
|
listar em cards (um por scorecard, itens com badge de peso),
|
||||||
|
remover.
|
||||||
|
- [x] IA > Prompts (`/app/ia/prompts`): criar template (finalidade
|
||||||
|
Análise/Scorecard + conteúdo v1), listar com o conteúdo da versão
|
||||||
|
ativa visível. "Editar" o conteúdo dispara `POST .../versions` +
|
||||||
|
`POST .../versions/:id/activate` numa ação só (create-então-ativa) —
|
||||||
|
não existe endpoint pra listar o histórico de versões, então a UI
|
||||||
|
não finge ter uma linha do tempo que não dá pra buscar; só o
|
||||||
|
conteúdo ativo é mostrado. Templates `Global` (da plataforma,
|
||||||
|
`tenantId=null`, RLS híbrida já resolve a união sem filtro no
|
||||||
|
client) aparecem mas sem botão de editar — só quem tem role de
|
||||||
|
plataforma pode alterar, e o backend rejeitaria com 403 mesmo que
|
||||||
|
o botão existisse.
|
||||||
|
- [x] IA > Configurações (`/app/ia/configuracoes`): providers (BYOK,
|
||||||
|
`scope: "TENANT"` sempre — a tela nem oferece GLOBAL, que já é
|
||||||
|
exclusivo de platform admin no backend) e modelos (referenciando um
|
||||||
|
provider, capacidades via checkboxes). Chave de API só aparece como
|
||||||
|
preview (`...xxxxxxxx`) mesmo na resposta de criação — nunca
|
||||||
|
reexibida em texto puro, nem uma vez (diferente do padrão "revela
|
||||||
|
uma vez" de Ramais; aqui a API nunca devolve o valor puro, ponto).
|
||||||
|
- [x] `Textarea` novo em `components/ui/input.tsx` (mesmo estilo de
|
||||||
|
`Input`/`Select`) — primeira tela que precisa de texto longo
|
||||||
|
(conteúdo de prompt).
|
||||||
|
- [x] Testado ponta a ponta contra a API real (tenant Acme): scorecard
|
||||||
|
"Atendimento" com critério "Saudação" criado e listado; template
|
||||||
|
"Análise padrão" criado (v1) → editado → v2 ativada e refletida na
|
||||||
|
tela; provider "OpenAI da Acme" (BYOK) criado com preview de chave
|
||||||
|
mascarada → modelo "GPT-4o mini" criado referenciando o provider,
|
||||||
|
com capacidade "Transcrição". Smoke test de regressão nas 16 telas
|
||||||
|
anteriores do tenant + platform, todas 200.
|
||||||
|
- [ ] Scorecards: sem edição de critérios existentes (só criar scorecard
|
||||||
|
inteiro ou remover) — mesma limitação de "só create/list/delete"
|
||||||
|
já documentada em outras telas simples
|
||||||
|
- [ ] IA > Configurações não distingue custo por modelo (`inputCost`/
|
||||||
|
`outputCost`/`audioCost` existem no backend, sem campo no form) —
|
||||||
|
decisão de escopo pra manter o formulário enxuto nesta primeira
|
||||||
|
versão
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Riscos conhecidos
|
## Riscos conhecidos
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/ia-config-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/frontend/.impeccable/review/ia-config-model-desktop.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
apps/frontend/.impeccable/review/ia-config-provider-desktop.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/frontend/.impeccable/review/prompts-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/frontend/.impeccable/review/prompts-version2-desktop.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/frontend/.impeccable/review/prompts-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/frontend/.impeccable/review/scorecards-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 77 KiB |
93
apps/frontend/src/app/app/ia/configuracoes/actions.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-types";
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||||
|
if (typeof parsed.message === "string") return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// corpo não era JSON
|
||||||
|
}
|
||||||
|
return err.message || "Falha inesperada na API.";
|
||||||
|
}
|
||||||
|
return "Falha inesperada. Tente novamente.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProviderInput {
|
||||||
|
providerType: string;
|
||||||
|
name: string;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
organization?: string;
|
||||||
|
project?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sempre `scope: "TENANT"` (BYOK) — esta tela é do tenant, GLOBAL é
|
||||||
|
* exclusivo de platform admin (o backend rejeita com 403 de qualquer
|
||||||
|
* forma, mas nem oferece a opção aqui). */
|
||||||
|
export async function createProvider(
|
||||||
|
input: CreateProviderInput,
|
||||||
|
): Promise<{ ok: true; provider: AIProviderConfig } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const provider = await apiFetch<AIProviderConfig>("/ai/providers", session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...input, scope: "TENANT" }),
|
||||||
|
});
|
||||||
|
revalidatePath("/app/ia/configuracoes");
|
||||||
|
return { ok: true, provider };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
await apiFetch<void>(`/ai/providers/${id}`, session.accessToken, { method: "DELETE" });
|
||||||
|
revalidatePath("/app/ia/configuracoes");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModelInput {
|
||||||
|
providerId: string;
|
||||||
|
externalModelId: string;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModel(
|
||||||
|
input: CreateModelInput,
|
||||||
|
): Promise<{ ok: true; model: AIModelConfig } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const model = await apiFetch<AIModelConfig>("/ai/models", session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
revalidatePath("/app/ia/configuracoes");
|
||||||
|
return { ok: true, model };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModel(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
await apiFetch<void>(`/ai/models/${id}`, session.accessToken, { method: "DELETE" });
|
||||||
|
revalidatePath("/app/ia/configuracoes");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Bot, Cpu, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITIES,
|
||||||
|
AI_CAPABILITY_LABELS,
|
||||||
|
PROVIDER_TYPES,
|
||||||
|
type AIModelConfig,
|
||||||
|
type AIProviderConfig,
|
||||||
|
} from "@/lib/ai-config-types";
|
||||||
|
import { createModel, createProvider, deleteModel, deleteProvider } from "./actions";
|
||||||
|
|
||||||
|
export function ConfiguracoesView({ providers, models }: { providers: AIProviderConfig[]; models: AIModelConfig[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">IA — Configurações</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Providers e modelos de IA (agente.md secao 96-103) — os que aparecem com <Pill tone="accent">Global</Pill>{" "}
|
||||||
|
vêm da plataforma e não podem ser alterados por aqui; o resto é BYOK deste tenant. A chave de API nunca é
|
||||||
|
reexibida depois de salva.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProvidersPanel providers={providers} />
|
||||||
|
<ModelsPanel models={models} providers={providers} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvidersPanel({ providers }: { providers: AIProviderConfig[] }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Providers</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{providers.length} provider(s) visível(is) deste tenant</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-3.5 w-3.5" aria-hidden /> : <Plus className="h-3.5 w-3.5" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo provider"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <NewProviderForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{providers.length === 0 ? (
|
||||||
|
<EmptyState title="Nenhum provider disponível ainda" description="Cadastre uma chave de API (BYOK) pra habilitar transcrição/análise." />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>Tipo</TH>
|
||||||
|
<TH>Chave</TH>
|
||||||
|
<TH>Escopo</TH>
|
||||||
|
<TH>
|
||||||
|
<span className="sr-only">Ações</span>
|
||||||
|
</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<TR key={p.id}>
|
||||||
|
<TD>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<Bot className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD className="font-mono text-muted-foreground">{p.providerType}</TD>
|
||||||
|
<TD className="font-mono text-xs text-muted-foreground">{p.apiKeyPreview}</TD>
|
||||||
|
<TD>
|
||||||
|
<Pill tone={p.scope === "GLOBAL" ? "accent" : "neutral"}>{p.scope === "GLOBAL" ? "Global" : "Deste tenant"}</Pill>
|
||||||
|
</TD>
|
||||||
|
<TD>{p.scope === "TENANT" && <DeleteButton onDelete={() => deleteProvider(p.id)} label={p.name} />}</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewProviderForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [providerType, setProviderType] = useState<string>(PROVIDER_TYPES[0]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim() || apiKey.trim().length < 10) {
|
||||||
|
setError("Nome obrigatório e chave de API com pelo menos 10 caracteres.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createProvider({ providerType, name: name.trim(), apiKey: apiKey.trim() });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pr-type">Tipo</FieldLabel>
|
||||||
|
<Select id="pr-type" value={providerType} onChange={(e) => setProviderType(e.target.value)} disabled={pending}>
|
||||||
|
{PROVIDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pr-name">Nome</FieldLabel>
|
||||||
|
<Input id="pr-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: OpenAI da Acme" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pr-key">Chave de API</FieldLabel>
|
||||||
|
<Input id="pr-key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" size="sm" disabled={pending}>
|
||||||
|
{pending ? "Salvando…" : "Salvar provider"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelsPanel({ models, providers }: { models: AIModelConfig[]; providers: AIProviderConfig[] }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const providersById = useMemo(() => Object.fromEntries(providers.map((p) => [p.id, p])), [providers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Modelos</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{models.length} modelo(s) configurado(s)</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={() => setShowForm((s) => !s)} disabled={providers.length === 0}>
|
||||||
|
{showForm ? <X className="h-3.5 w-3.5" aria-hidden /> : <Plus className="h-3.5 w-3.5" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo modelo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providers.length === 0 && !showForm && (
|
||||||
|
<p className="px-5 py-3 text-sm text-muted-foreground">Cadastre um provider primeiro pra poder configurar um modelo.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && <NewModelForm providers={providers} onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<EmptyState title="Nenhum modelo configurado ainda" description="Configure o primeiro modelo a partir de um provider." />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>Provider</TH>
|
||||||
|
<TH>Modelo (ID externo)</TH>
|
||||||
|
<TH>Capacidades</TH>
|
||||||
|
<TH>
|
||||||
|
<span className="sr-only">Ações</span>
|
||||||
|
</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<TR key={m.id}>
|
||||||
|
<TD>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<Cpu className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{m.displayName}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD className="text-muted-foreground">{providersById[m.providerId]?.name ?? m.providerId}</TD>
|
||||||
|
<TD className="font-mono text-muted-foreground">{m.externalModelId}</TD>
|
||||||
|
<TD>
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
{m.capabilities.map((c) => (
|
||||||
|
<Pill key={c}>{AI_CAPABILITY_LABELS[c] ?? c}</Pill>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD>
|
||||||
|
<DeleteButton onDelete={() => deleteModel(m.id)} label={m.displayName} />
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewModelForm({ providers, onDone }: { providers: AIProviderConfig[]; onDone: () => void }) {
|
||||||
|
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
|
||||||
|
const [externalModelId, setExternalModelId] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [capabilities, setCapabilities] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function toggleCapability(cap: string) {
|
||||||
|
setCapabilities((prev) => (prev.includes(cap) ? prev.filter((c) => c !== cap) : [...prev, cap]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!providerId || !externalModelId.trim() || !displayName.trim()) {
|
||||||
|
setError("Provider, ID do modelo e nome de exibição são obrigatórios.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createModel({
|
||||||
|
providerId,
|
||||||
|
externalModelId: externalModelId.trim(),
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="md-provider">Provider</FieldLabel>
|
||||||
|
<Select id="md-provider" value={providerId} onChange={(e) => setProviderId(e.target.value)} disabled={pending}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="md-external">ID do modelo (externo)</FieldLabel>
|
||||||
|
<Input id="md-external" value={externalModelId} onChange={(e) => setExternalModelId(e.target.value)} placeholder="gpt-4o-mini" className="font-mono" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="md-name">Nome de exibição</FieldLabel>
|
||||||
|
<Input id="md-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="GPT-4o mini" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="md-cap-0">Capacidades</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{AI_CAPABILITIES.map((cap, i) => (
|
||||||
|
<label key={cap} className="flex items-center gap-1.5 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
id={i === 0 ? "md-cap-0" : undefined}
|
||||||
|
type="checkbox"
|
||||||
|
checked={capabilities.includes(cap)}
|
||||||
|
onChange={() => toggleCapability(cap)}
|
||||||
|
disabled={pending}
|
||||||
|
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{AI_CAPABILITY_LABELS[cap]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" size="sm" disabled={pending}>
|
||||||
|
{pending ? "Salvando…" : "Salvar modelo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteButton({ onDelete, label }: { onDelete: () => Promise<{ ok: true } | { ok: false; error: string }>; label: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (!confirming) {
|
||||||
|
setConfirming(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await onDelete();
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
setConfirming(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={confirming ? "destructive" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={confirming ? `Confirmar remoção de ${label}` : `Remover ${label}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{confirming ? "Confirmar" : ""}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/frontend/src/app/app/ia/configuracoes/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-types";
|
||||||
|
import { ConfiguracoesView } from "./configuracoes-view";
|
||||||
|
|
||||||
|
export default async function ConfiguracoesPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
|
||||||
|
const [providers, models] = await Promise.all([
|
||||||
|
apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken),
|
||||||
|
apiFetch<AIModelConfig[]>("/ai/models", session.accessToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <ConfiguracoesView providers={providers} models={models} />;
|
||||||
|
}
|
||||||
68
apps/frontend/src/app/app/ia/prompts/actions.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { AIPromptTemplate, AIPromptVersion } from "@/lib/prompt-types";
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||||
|
if (typeof parsed.message === "string") return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// corpo não era JSON
|
||||||
|
}
|
||||||
|
return err.message || "Falha inesperada na API.";
|
||||||
|
}
|
||||||
|
return "Falha inesperada. Tente novamente.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePromptTemplateInput {
|
||||||
|
purpose: "ANALYSIS" | "SCORECARD";
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPromptTemplate(
|
||||||
|
input: CreatePromptTemplateInput,
|
||||||
|
): Promise<{ ok: true; template: AIPromptTemplate } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const template = await apiFetch<AIPromptTemplate>("/ai/prompt-templates", session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
revalidatePath("/app/ia/prompts");
|
||||||
|
return { ok: true, template };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Editar" o conteúdo = criar uma versão nova e ativá-la na mesma ação —
|
||||||
|
* o backend nunca edita versão in-place (secao 114-116), e não existe
|
||||||
|
* endpoint pra listar todo o histórico, então a UI não tenta mostrar um
|
||||||
|
* histórico que não dá pra buscar; só o conteúdo ativo importa aqui. */
|
||||||
|
export async function updatePromptContent(
|
||||||
|
templateId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<{ ok: true; template: AIPromptTemplate } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const version = await apiFetch<AIPromptVersion>(`/ai/prompt-templates/${templateId}/versions`, session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
const template = await apiFetch<AIPromptTemplate>(
|
||||||
|
`/ai/prompt-templates/${templateId}/versions/${version.id}/activate`,
|
||||||
|
session.accessToken,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
revalidatePath("/app/ia/prompts");
|
||||||
|
return { ok: true, template };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/app/ia/prompts/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { AIPromptTemplate } from "@/lib/prompt-types";
|
||||||
|
import { PromptsView } from "./prompts-view";
|
||||||
|
|
||||||
|
export default async function PromptsPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const templates = await apiFetch<AIPromptTemplate[]>("/ai/prompt-templates", session.accessToken);
|
||||||
|
return <PromptsView templates={templates} />;
|
||||||
|
}
|
||||||
173
apps/frontend/src/app/app/ia/prompts/prompts-view.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FileText, Plus, X } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, Select, Textarea, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { EmptyState } from "@/components/ui/table";
|
||||||
|
import { PROMPT_PURPOSE_LABELS, type AIPromptTemplate } from "@/lib/prompt-types";
|
||||||
|
import { createPromptTemplate, updatePromptContent } from "./actions";
|
||||||
|
|
||||||
|
export function PromptsView({ templates }: { templates: AIPromptTemplate[] }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Prompts de IA</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Templates usados na análise de chamada e na avaliação de scorecard (agente.md secao 114-116). O
|
||||||
|
conteúdo nunca é editado no lugar — trocar o texto cria uma versão nova e a ativa. Templates{" "}
|
||||||
|
<Pill>Global</Pill> vêm da plataforma; só quem tem role de plataforma pode alterá-los.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <NewTemplateForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Nenhum template cadastrado ainda" description="Crie o primeiro template de prompt." />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<TemplateCard key={t.id} template={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCard({ template }: { template: AIPromptTemplate }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [content, setContent] = useState(template.activeVersion?.content ?? "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const isGlobal = template.tenantId === null;
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updatePromptContent(template.id, content);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{template.name}</h2>
|
||||||
|
<Pill>{PROMPT_PURPOSE_LABELS[template.purpose]}</Pill>
|
||||||
|
{isGlobal && <Pill tone="accent">Global</Pill>}
|
||||||
|
{template.activeVersion && <span className="text-xs text-muted-foreground">v{template.activeVersion.version}</span>}
|
||||||
|
</div>
|
||||||
|
{!isGlobal && !editing && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Editar conteúdo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={8} className="font-mono text-xs" disabled={pending} />
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
||||||
|
{pending ? "Salvando…" : "Salvar como nova versão"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
|
||||||
|
{template.activeVersion?.content || "Sem conteúdo ativo ainda."}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewTemplateForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [purpose, setPurpose] = useState<"ANALYSIS" | "SCORECARD">("ANALYSIS");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim() || !content.trim()) {
|
||||||
|
setError("Nome e conteúdo são obrigatórios.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createPromptTemplate({ purpose, name: name.trim(), content: content.trim() });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pt-name">Nome</FieldLabel>
|
||||||
|
<Input id="pt-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Análise padrão" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pt-purpose">Finalidade</FieldLabel>
|
||||||
|
<Select id="pt-purpose" value={purpose} onChange={(e) => setPurpose(e.target.value as "ANALYSIS" | "SCORECARD")} disabled={pending}>
|
||||||
|
<option value="ANALYSIS">Análise de chamada</option>
|
||||||
|
<option value="SCORECARD">Avaliação de scorecard</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pt-content">Conteúdo (v1)</FieldLabel>
|
||||||
|
<Textarea id="pt-content" value={content} onChange={(e) => setContent(e.target.value)} rows={6} className="font-mono text-xs" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Criando…" : "Criar template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/frontend/src/app/app/ia/scorecards/actions.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { QualityScorecard } from "@/lib/quality-types";
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||||
|
if (typeof parsed.message === "string") return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// corpo não era JSON
|
||||||
|
}
|
||||||
|
return err.message || "Falha inesperada na API.";
|
||||||
|
}
|
||||||
|
return "Falha inesperada. Tente novamente.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScorecardItemInput {
|
||||||
|
name: string;
|
||||||
|
weight?: number;
|
||||||
|
description?: string;
|
||||||
|
evaluationPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScorecardInput {
|
||||||
|
name: string;
|
||||||
|
items: CreateScorecardItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createScorecard(
|
||||||
|
input: CreateScorecardInput,
|
||||||
|
): Promise<{ ok: true; scorecard: QualityScorecard } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const scorecard = await apiFetch<QualityScorecard>("/quality/scorecards", session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
revalidatePath("/app/ia/scorecards");
|
||||||
|
return { ok: true, scorecard };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteScorecard(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
await apiFetch<void>(`/quality/scorecards/${id}`, session.accessToken, { method: "DELETE" });
|
||||||
|
revalidatePath("/app/ia/scorecards");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/app/ia/scorecards/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { QualityScorecard } from "@/lib/quality-types";
|
||||||
|
import { ScorecardsView } from "./scorecards-view";
|
||||||
|
|
||||||
|
export default async function ScorecardsPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const scorecards = await apiFetch<QualityScorecard[]>("/quality/scorecards", session.accessToken);
|
||||||
|
return <ScorecardsView scorecards={scorecards} />;
|
||||||
|
}
|
||||||
222
apps/frontend/src/app/app/ia/scorecards/scorecards-view.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ClipboardList, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { EmptyState } from "@/components/ui/table";
|
||||||
|
import type { QualityScorecard } from "@/lib/quality-types";
|
||||||
|
import { createScorecard, deleteScorecard, type CreateScorecardItemInput } from "./actions";
|
||||||
|
|
||||||
|
export function ScorecardsView({ scorecards }: { scorecards: QualityScorecard[] }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Scorecards de QA</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Critérios ponderados que a IA avalia em toda chamada analisada (agente.md secao 117-118) — sem lista
|
||||||
|
fixa, cada tenant define os seus. Editar um scorecard não muda avaliações já feitas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo scorecard"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <NewScorecardForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{scorecards.length === 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Nenhum scorecard cadastrado ainda" description="Crie o primeiro, ex.: Atendimento, Vendas." />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{scorecards.map((sc) => (
|
||||||
|
<ScorecardCard key={sc.id} scorecard={sc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorecardCard({ scorecard }: { scorecard: QualityScorecard }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onDelete() {
|
||||||
|
if (!confirming) {
|
||||||
|
setConfirming(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteScorecard(scorecard.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
setConfirming(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title={scorecard.name} description={`${scorecard.items.length} critério(s)`} />
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{scorecard.items.map((item) => (
|
||||||
|
<li key={item.id} className="flex items-center justify-between gap-3 px-5 py-3 text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-foreground">
|
||||||
|
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<Pill>peso {item.weight}</Pill>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t border-border px-5 py-3">
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={confirming ? "destructive" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={confirming ? `Confirmar remoção de ${scorecard.name}` : `Remover ${scorecard.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{confirming ? "Confirmar" : "Remover"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewScorecardForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [items, setItems] = useState<CreateScorecardItemInput[]>([{ name: "", weight: 1 }]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<CreateScorecardItemInput>) {
|
||||||
|
setItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...patch } : it)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Dê um nome ao scorecard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validItems = items.filter((it) => it.name.trim());
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
setError("Adicione pelo menos um critério com nome.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createScorecard({
|
||||||
|
name: name.trim(),
|
||||||
|
items: validItems.map((it) => ({
|
||||||
|
name: it.name.trim(),
|
||||||
|
weight: it.weight,
|
||||||
|
description: it.description?.trim() || undefined,
|
||||||
|
evaluationPrompt: it.evaluationPrompt?.trim() || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="sc-name">Nome do scorecard</FieldLabel>
|
||||||
|
<Input id="sc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Atendimento" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<FieldLabel htmlFor="sc-item-0">Critérios</FieldLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setItems((prev) => [...prev, { name: "", weight: 1 }])}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Adicionar critério
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-1 gap-2 rounded-md border border-border p-3 sm:grid-cols-[1fr_auto_auto]">
|
||||||
|
<Input
|
||||||
|
id={`sc-item-${i}`}
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateItem(i, { name: e.target.value })}
|
||||||
|
placeholder="Ex.: Saudação"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={item.weight ?? 1}
|
||||||
|
onChange={(e) => updateItem(i, { weight: Number(e.target.value) })}
|
||||||
|
className="w-24"
|
||||||
|
aria-label={`Peso do critério ${i + 1}`}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setItems((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
disabled={pending || items.length <= 1}
|
||||||
|
aria-label={`Remover critério ${i + 1}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={item.description ?? ""}
|
||||||
|
onChange={(e) => updateItem(i, { description: e.target.value })}
|
||||||
|
placeholder="Descrição (opcional)"
|
||||||
|
className="sm:col-span-3"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Criando…" : "Criar scorecard"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -114,9 +114,21 @@ export const TENANT_NAV: NavSection[] = [
|
|||||||
href: "/app/relatorios/ia",
|
href: "/app/relatorios/ia",
|
||||||
description: "Dashboard de IA — score, sentimento, assuntos, compliance",
|
description: "Dashboard de IA — score, sentimento, assuntos, compliance",
|
||||||
},
|
},
|
||||||
{ label: "Scorecards" },
|
{
|
||||||
{ label: "Prompts" },
|
label: "Scorecards",
|
||||||
{ label: "Configurações" },
|
href: "/app/ia/scorecards",
|
||||||
|
description: "Critérios ponderados que a IA avalia em toda chamada analisada",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Prompts",
|
||||||
|
href: "/app/ia/prompts",
|
||||||
|
description: "Templates de prompt usados na análise e no scorecard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Configurações",
|
||||||
|
href: "/app/ia/configuracoes",
|
||||||
|
description: "Providers e modelos de IA — BYOK deste tenant",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
|||||||
);
|
);
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
||||||
({ className, children, ...props }, ref) => (
|
({ className, children, ...props }, ref) => (
|
||||||
<select
|
<select
|
||||||
|
|||||||
40
apps/frontend/src/lib/ai-config-types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export interface AIProviderConfig {
|
||||||
|
id: string;
|
||||||
|
scope: "GLOBAL" | "TENANT";
|
||||||
|
tenantId: string | null;
|
||||||
|
providerType: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
project: string | null;
|
||||||
|
apiKeyPreview: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_TYPES = ["openai", "anthropic"] as const;
|
||||||
|
|
||||||
|
export interface AIModelConfig {
|
||||||
|
id: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
providerId: string;
|
||||||
|
externalModelId: string;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: string[];
|
||||||
|
inputCost: number | null;
|
||||||
|
outputCost: number | null;
|
||||||
|
audioCost: number | null;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_CAPABILITIES = ["TRANSCRIPTION", "DIARIZATION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS", "REALTIME_AUDIO"] as const;
|
||||||
|
|
||||||
|
export const AI_CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
TRANSCRIPTION: "Transcrição",
|
||||||
|
DIARIZATION: "Diarização",
|
||||||
|
TEXT_ANALYSIS: "Análise de texto",
|
||||||
|
STRUCTURED_OUTPUT: "Saída estruturada",
|
||||||
|
EMBEDDINGS: "Embeddings",
|
||||||
|
REALTIME_AUDIO: "Áudio em tempo real",
|
||||||
|
};
|
||||||
21
apps/frontend/src/lib/prompt-types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface AIPromptVersion {
|
||||||
|
id: string;
|
||||||
|
templateId: string;
|
||||||
|
version: number;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIPromptTemplate {
|
||||||
|
id: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
purpose: "ANALYSIS" | "SCORECARD";
|
||||||
|
name: string;
|
||||||
|
activeVersionId: string | null;
|
||||||
|
activeVersion: AIPromptVersion | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROMPT_PURPOSE_LABELS: Record<string, string> = {
|
||||||
|
ANALYSIS: "Análise de chamada",
|
||||||
|
SCORECARD: "Avaliação de scorecard",
|
||||||
|
};
|
||||||
14
apps/frontend/src/lib/quality-types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface QualityScorecardItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
description: string | null;
|
||||||
|
evaluationPrompt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityScorecard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
items: QualityScorecardItem[];
|
||||||
|
}
|
||||||