diff --git a/TODO.md b/TODO.md index b653cc3..98a3b6e 100644 --- a/TODO.md +++ b/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 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 diff --git a/apps/frontend/.impeccable/review/ia-config-empty-desktop.png b/apps/frontend/.impeccable/review/ia-config-empty-desktop.png new file mode 100644 index 0000000..ecf1702 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-config-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ia-config-model-desktop.png b/apps/frontend/.impeccable/review/ia-config-model-desktop.png new file mode 100644 index 0000000..0223ef1 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-config-model-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ia-config-provider-desktop.png b/apps/frontend/.impeccable/review/ia-config-provider-desktop.png new file mode 100644 index 0000000..78ace65 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-config-provider-desktop.png differ diff --git a/apps/frontend/.impeccable/review/prompts-empty-desktop.png b/apps/frontend/.impeccable/review/prompts-empty-desktop.png new file mode 100644 index 0000000..bd08adb Binary files /dev/null and b/apps/frontend/.impeccable/review/prompts-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/prompts-version2-desktop.png b/apps/frontend/.impeccable/review/prompts-version2-desktop.png new file mode 100644 index 0000000..99b25dd Binary files /dev/null and b/apps/frontend/.impeccable/review/prompts-version2-desktop.png differ diff --git a/apps/frontend/.impeccable/review/prompts-with-data-desktop.png b/apps/frontend/.impeccable/review/prompts-with-data-desktop.png new file mode 100644 index 0000000..ccdf1d1 Binary files /dev/null and b/apps/frontend/.impeccable/review/prompts-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/scorecards-empty-desktop.png b/apps/frontend/.impeccable/review/scorecards-empty-desktop.png new file mode 100644 index 0000000..939a6e2 Binary files /dev/null and b/apps/frontend/.impeccable/review/scorecards-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/scorecards-with-data-desktop.png b/apps/frontend/.impeccable/review/scorecards-with-data-desktop.png new file mode 100644 index 0000000..8e83076 Binary files /dev/null and b/apps/frontend/.impeccable/review/scorecards-with-data-desktop.png differ diff --git a/apps/frontend/src/app/app/ia/configuracoes/actions.ts b/apps/frontend/src/app/app/ia/configuracoes/actions.ts new file mode 100644 index 0000000..6c58f18 --- /dev/null +++ b/apps/frontend/src/app/app/ia/configuracoes/actions.ts @@ -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("/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(`/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("/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(`/ai/models/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/ia/configuracoes"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/ia/configuracoes/configuracoes-view.tsx b/apps/frontend/src/app/app/ia/configuracoes/configuracoes-view.tsx new file mode 100644 index 0000000..07873d7 --- /dev/null +++ b/apps/frontend/src/app/app/ia/configuracoes/configuracoes-view.tsx @@ -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 ( +
+
+

IA — Configurações

+

+ Providers e modelos de IA (agente.md secao 96-103) — os que aparecem com Global{" "} + 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. +

+
+ + + +
+ ); +} + +function ProvidersPanel({ providers }: { providers: AIProviderConfig[] }) { + const [showForm, setShowForm] = useState(false); + + return ( + +
+
+

Providers

+

{providers.length} provider(s) visível(is) deste tenant

+
+ +
+ + {showForm && setShowForm(false)} />} + + {providers.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {providers.map((p) => ( + + + + + + + + ))} + +
NomeTipoChaveEscopo + Ações +
+ + + {p.name} + + {p.providerType}{p.apiKeyPreview} + {p.scope === "GLOBAL" ? "Global" : "Deste tenant"} + {p.scope === "TENANT" && deleteProvider(p.id)} label={p.name} />}
+ )} +
+ ); +} + +function NewProviderForm({ onDone }: { onDone: () => void }) { + const [providerType, setProviderType] = useState(PROVIDER_TYPES[0]); + const [name, setName] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(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 ( +
+
+
+
+ Tipo + +
+
+ Nome + setName(e.target.value)} placeholder="Ex.: OpenAI da Acme" disabled={pending} /> +
+
+ Chave de API + setApiKey(e.target.value)} placeholder="sk-…" disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +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 ( + +
+
+

Modelos

+

{models.length} modelo(s) configurado(s)

+
+ +
+ + {providers.length === 0 && !showForm && ( +

Cadastre um provider primeiro pra poder configurar um modelo.

+ )} + + {showForm && setShowForm(false)} />} + + {models.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {models.map((m) => ( + + + + + + + + ))} + +
NomeProviderModelo (ID externo)Capacidades + Ações +
+ + + {m.displayName} + + {providersById[m.providerId]?.name ?? m.providerId}{m.externalModelId} + + {m.capabilities.map((c) => ( + {AI_CAPABILITY_LABELS[c] ?? c} + ))} + + + deleteModel(m.id)} label={m.displayName} /> +
+ )} +
+ ); +} + +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([]); + const [error, setError] = useState(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 ( +
+
+
+
+ Provider + +
+
+ ID do modelo (externo) + setExternalModelId(e.target.value)} placeholder="gpt-4o-mini" className="font-mono" disabled={pending} /> +
+
+ Nome de exibição + setDisplayName(e.target.value)} placeholder="GPT-4o mini" disabled={pending} /> +
+
+
+ Capacidades +
+ {AI_CAPABILITIES.map((cap, i) => ( + + ))} +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +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(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 ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/ia/configuracoes/page.tsx b/apps/frontend/src/app/app/ia/configuracoes/page.tsx new file mode 100644 index 0000000..c693816 --- /dev/null +++ b/apps/frontend/src/app/app/ia/configuracoes/page.tsx @@ -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("/ai/providers", session.accessToken), + apiFetch("/ai/models", session.accessToken), + ]); + + return ; +} diff --git a/apps/frontend/src/app/app/ia/prompts/actions.ts b/apps/frontend/src/app/app/ia/prompts/actions.ts new file mode 100644 index 0000000..71e406c --- /dev/null +++ b/apps/frontend/src/app/app/ia/prompts/actions.ts @@ -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("/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(`/ai/prompt-templates/${templateId}/versions`, session.accessToken, { + method: "POST", + body: JSON.stringify({ content }), + }); + const template = await apiFetch( + `/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) }; + } +} diff --git a/apps/frontend/src/app/app/ia/prompts/page.tsx b/apps/frontend/src/app/app/ia/prompts/page.tsx new file mode 100644 index 0000000..111f3f1 --- /dev/null +++ b/apps/frontend/src/app/app/ia/prompts/page.tsx @@ -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("/ai/prompt-templates", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/ia/prompts/prompts-view.tsx b/apps/frontend/src/app/app/ia/prompts/prompts-view.tsx new file mode 100644 index 0000000..b2182ea --- /dev/null +++ b/apps/frontend/src/app/app/ia/prompts/prompts-view.tsx @@ -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 ( +
+
+
+

Prompts de IA

+

+ 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{" "} + Global vêm da plataforma; só quem tem role de plataforma pode alterá-los. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + {templates.length === 0 ? ( + + + + ) : ( +
+ {templates.map((t) => ( + + ))} +
+ )} +
+ ); +} + +function TemplateCard({ template }: { template: AIPromptTemplate }) { + const router = useRouter(); + const [editing, setEditing] = useState(false); + const [content, setContent] = useState(template.activeVersion?.content ?? ""); + const [error, setError] = useState(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 ( + +
+
+ +

{template.name}

+ {PROMPT_PURPOSE_LABELS[template.purpose]} + {isGlobal && Global} + {template.activeVersion && v{template.activeVersion.version}} +
+ {!isGlobal && !editing && ( + + )} +
+
+ {editing ? ( +
+