feat(frontend): Platform > IA > Providers/Modelos/Uso/Custos + achado real de autorização
Providers/Modelos reaproveitam os endpoints /ai/providers e /ai/models já existentes (só filtram scope GLOBAL na tela); Modelos expõe custo unitário (inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só platform precisa cadastrar preço. GET /platform/ai-usage (novo) agrega uso bruto de AIUsageRecord de todos os tenants no mês corrente (Uso) e estima custo casando cada registro com o AIModel correspondente (Custos) — quando não dá pra casar, o registro fica de fora da soma e o tenant é marcado costIncomplete, nunca um número inventado. Achado real de autorização ao revisar AIModelsController antes de construir a tela de Modelos: POST/DELETE /ai/models não checava isPlatformUser quando o alvo era scope=GLOBAL — como a RLS híbrida (OR tenant_id IS NULL) deixa qualquer tenant ENXERGAR um provider/modelo GLOBAL, qualquer Tenant Admin com `ai.manage` (permission de escopo TENANT) conseguia injetar um modelo no catálogo global ou desabilitar um modelo GLOBAL só sabendo o id. O endpoint irmão (AIProvidersController) já tinha o check certo; corrigido com o mesmo padrão. Confirmado com um teste de ataque real: 403 depois do fix (era 201/sucesso antes), com regressão confirmando que BYOK do próprio tenant continua funcionando normalmente. Testado ponta a ponta: provider+modelo GLOBAL com custo real, uso de teste inserido direto no Postgres, /platform/ai-usage devolvendo o valor exato esperado (bate com a conta manual), tudo removido no final. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
63
apps/frontend/src/app/platform/ia/custos/custos-view.tsx
Normal file
63
apps/frontend/src/app/platform/ia/custos/custos-view.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { TenantAiUsage } from "@/lib/platform-types";
|
||||
|
||||
export function CustosView({ usage }: { usage: TenantAiUsage[] }) {
|
||||
const total = usage.reduce((sum, u) => sum + (u.estimatedCost ?? 0), 0);
|
||||
const anyIncomplete = usage.some((u) => u.costIncomplete);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">IA — Custos</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Uso × custo unitário cadastrado em IA > Modelos, mês corrente. Quando um registro de uso não bate com
|
||||
nenhum modelo cadastrado (provider apagado, custo nunca preenchido), ele fica de fora da soma — nunca um
|
||||
número inventado no lugar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel className="p-5">
|
||||
<PanelHeader title="Total estimado (todos os tenants)" />
|
||||
<p className="mt-2 font-mono text-3xl font-semibold tabular-nums text-foreground">{formatCurrency(total, "USD")}</p>
|
||||
{anyIncomplete && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Pelo menos um tenant tem uso sem custo cadastrado (marcado abaixo) — o total real é maior que este número.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Custo por tenant" />
|
||||
{usage.length === 0 ? (
|
||||
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Tenant</TH>
|
||||
<TH>Custo estimado</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Aviso</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{usage.map((u) => (
|
||||
<TR key={u.tenantId}>
|
||||
<TD className="text-foreground">{u.legalName}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">
|
||||
{u.estimatedCost != null ? formatCurrency(u.estimatedCost, "USD") : "—"}
|
||||
</TD>
|
||||
<TD>{u.costIncomplete && <Pill>Uso sem custo cadastrado</Pill>}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/platform/ia/custos/page.tsx
Normal file
10
apps/frontend/src/app/platform/ia/custos/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { TenantAiUsage } from "@/lib/platform-types";
|
||||
import { CustosView } from "./custos-view";
|
||||
|
||||
export default async function CustosPage() {
|
||||
const session = await requireSession();
|
||||
const usage = await apiFetch<TenantAiUsage[]>("/platform/ai-usage", session.accessToken);
|
||||
return <CustosView usage={usage} />;
|
||||
}
|
||||
57
apps/frontend/src/app/platform/ia/modelos/actions.ts
Normal file
57
apps/frontend/src/app/platform/ia/modelos/actions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { AIModelConfig } 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 CreateGlobalModelInput {
|
||||
providerId: string;
|
||||
externalModelId: string;
|
||||
displayName: string;
|
||||
capabilities: string[];
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
audioCost?: number;
|
||||
}
|
||||
|
||||
export async function createGlobalModel(
|
||||
input: CreateGlobalModelInput,
|
||||
): 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("/platform/ia/modelos");
|
||||
return { ok: true, model };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGlobalModel(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("/platform/ia/modelos");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
246
apps/frontend/src/app/platform/ia/modelos/modelos-view.tsx
Normal file
246
apps/frontend/src/app/platform/ia/modelos/modelos-view.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { 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 { formatCurrency } from "@/lib/format";
|
||||
import { AI_CAPABILITIES, AI_CAPABILITY_LABELS, type AIModelConfig, type AIProviderConfig } from "@/lib/ai-config-types";
|
||||
import { createGlobalModel, deleteGlobalModel } from "./actions";
|
||||
|
||||
export function ModelosView({ providers, models }: { providers: AIProviderConfig[]; models: AIModelConfig[] }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const providersById = useMemo(() => Object.fromEntries(providers.map((p) => [p.id, p])), [providers]);
|
||||
|
||||
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">IA — Modelos</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Modelos dos providers globais, com custo unitário — usado pra estimar gasto em IA > Custos (agente.md
|
||||
secao 102-103). Deixe em branco o que não se aplica ao modelo.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)} disabled={providers.length === 0}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Novo modelo"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers.length === 0 && (
|
||||
<Panel className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Cadastre um provider global em IA > Providers primeiro.</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{showForm && <NewModelForm providers={providers} onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Modelos globais" description={`${models.length} modelo(s)`} />
|
||||
{models.length === 0 ? (
|
||||
<EmptyState title="Nenhum modelo configurado ainda" description="Configure o primeiro a partir de um provider global." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Provider</TH>
|
||||
<TH>ID externo</TH>
|
||||
<TH>Capacidades</TH>
|
||||
<TH>Custo (entrada/saída/áudio)</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 className="font-mono text-xs text-muted-foreground">
|
||||
{m.inputCost != null ? formatCurrency(m.inputCost, "USD") : "—"} /{" "}
|
||||
{m.outputCost != null ? formatCurrency(m.outputCost, "USD") : "—"} /{" "}
|
||||
{m.audioCost != null ? formatCurrency(m.audioCost, "USD") : "—"}
|
||||
</TD>
|
||||
<TD>
|
||||
<DeleteButton onDelete={() => deleteGlobalModel(m.id)} label={m.displayName} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [inputCost, setInputCost] = useState("");
|
||||
const [outputCost, setOutputCost] = useState("");
|
||||
const [audioCost, setAudioCost] = useState("");
|
||||
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 createGlobalModel({
|
||||
providerId,
|
||||
externalModelId: externalModelId.trim(),
|
||||
displayName: displayName.trim(),
|
||||
capabilities,
|
||||
inputCost: inputCost ? Number(inputCost) : undefined,
|
||||
outputCost: outputCost ? Number(outputCost) : undefined,
|
||||
audioCost: audioCost ? Number(audioCost) : undefined,
|
||||
});
|
||||
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-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 className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<FieldLabel htmlFor="md-input-cost">Custo por token de entrada (USD)</FieldLabel>
|
||||
<Input id="md-input-cost" type="number" min="0" step="0.000001" value={inputCost} onChange={(e) => setInputCost(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="md-output-cost">Custo por token de saída (USD)</FieldLabel>
|
||||
<Input id="md-output-cost" type="number" min="0" step="0.000001" value={outputCost} onChange={(e) => setOutputCost(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="md-audio-cost">Custo por segundo de áudio (USD)</FieldLabel>
|
||||
<Input id="md-audio-cost" type="number" min="0" step="0.000001" value={audioCost} onChange={(e) => setAudioCost(e.target.value)} 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" disabled={pending}>
|
||||
{pending ? "Salvando…" : "Salvar modelo"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
apps/frontend/src/app/platform/ia/modelos/page.tsx
Normal file
13
apps/frontend/src/app/platform/ia/modelos/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-types";
|
||||
import { ModelosView } from "./modelos-view";
|
||||
|
||||
export default async function PlatformModelosPage() {
|
||||
const session = await requireSession();
|
||||
const [providers, models] = await Promise.all([
|
||||
apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken),
|
||||
apiFetch<AIModelConfig[]>("/ai/models", session.accessToken),
|
||||
]);
|
||||
return <ModelosView providers={providers} models={models} />;
|
||||
}
|
||||
59
apps/frontend/src/app/platform/ia/providers/actions.ts
Normal file
59
apps/frontend/src/app/platform/ia/providers/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 { 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 CreateGlobalProviderInput {
|
||||
providerType: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
organization?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
/** Sempre `scope: "GLOBAL"` — visível de qualquer tenant (agente.md secao
|
||||
* 100). O backend também checa `isPlatformUser` explicitamente, esta
|
||||
* tela é só quem já está em `/platform`. */
|
||||
export async function createGlobalProvider(
|
||||
input: CreateGlobalProviderInput,
|
||||
): 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: "GLOBAL" }),
|
||||
});
|
||||
revalidatePath("/platform/ia/providers");
|
||||
return { ok: true, provider };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGlobalProvider(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("/platform/ia/providers");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
10
apps/frontend/src/app/platform/ia/providers/page.tsx
Normal file
10
apps/frontend/src/app/platform/ia/providers/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { AIProviderConfig } from "@/lib/ai-config-types";
|
||||
import { ProvidersView } from "./providers-view";
|
||||
|
||||
export default async function PlatformProvidersPage() {
|
||||
const session = await requireSession();
|
||||
const providers = await apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken);
|
||||
return <ProvidersView providers={providers} />;
|
||||
}
|
||||
175
apps/frontend/src/app/platform/ia/providers/providers-view.tsx
Normal file
175
apps/frontend/src/app/platform/ia/providers/providers-view.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bot, 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 { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { PROVIDER_TYPES, type AIProviderConfig } from "@/lib/ai-config-types";
|
||||
import { createGlobalProvider, deleteGlobalProvider } from "./actions";
|
||||
|
||||
export function ProvidersView({ providers }: { providers: AIProviderConfig[] }) {
|
||||
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">IA — Providers</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Providers de IA de escopo global (agente.md secao 100) — visíveis por qualquer tenant, diferente do BYOK
|
||||
que cada tenant cadastra em Configurações. A chave de API nunca é reexibida depois de salva.
|
||||
</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 provider global"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewProviderForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Providers globais" description={`${providers.length} provider(s)`} />
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState title="Nenhum provider global ainda" description="Cadastre uma chave de API pra disponibilizar pra todos os tenants." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Tipo</TH>
|
||||
<TH>Chave</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{providers.map((p) => (
|
||||
<ProviderRow key={p.id} provider={p} />
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 createGlobalProvider({ providerType, name: name.trim(), apiKey: apiKey.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-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 plataforma" 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" disabled={pending}>
|
||||
{pending ? "Salvando…" : "Salvar provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({ provider }: { provider: AIProviderConfig }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onDelete() {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await deleteGlobalProvider(provider.id);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TR>
|
||||
<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 />
|
||||
{provider.name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="font-mono text-muted-foreground">{provider.providerType}</TD>
|
||||
<TD className="font-mono text-xs text-muted-foreground">{provider.apiKeyPreview}</TD>
|
||||
<TD>
|
||||
<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={onDelete}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${provider.name}` : `Remover ${provider.name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/platform/ia/uso/page.tsx
Normal file
10
apps/frontend/src/app/platform/ia/uso/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { TenantAiUsage } from "@/lib/platform-types";
|
||||
import { UsoView } from "./uso-view";
|
||||
|
||||
export default async function UsoPage() {
|
||||
const session = await requireSession();
|
||||
const usage = await apiFetch<TenantAiUsage[]>("/platform/ai-usage", session.accessToken);
|
||||
return <UsoView usage={usage} />;
|
||||
}
|
||||
52
apps/frontend/src/app/platform/ia/uso/uso-view.tsx
Normal file
52
apps/frontend/src/app/platform/ia/uso/uso-view.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatDuration, formatInt } from "@/lib/format";
|
||||
import type { TenantAiUsage } from "@/lib/platform-types";
|
||||
|
||||
export function UsoView({ usage }: { usage: TenantAiUsage[] }) {
|
||||
const withUsage = usage.filter(
|
||||
(u) => u.usage.transcriptionSeconds > 0 || u.usage.analysisRequests > 0 || u.usage.inputTokens > 0 || u.usage.outputTokens > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">IA — Uso</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Uso bruto de IA de todos os tenants no mês corrente (agente.md secao 124, ledger{" "}
|
||||
<code className="font-mono text-xs">AIUsageRecord</code>) — quantidade, não custo (isso é IA > Custos).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Uso por tenant" description={`${withUsage.length} de ${usage.length} tenant(s) com uso este mês`} />
|
||||
{usage.length === 0 ? (
|
||||
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Tenant</TH>
|
||||
<TH>Transcrição</TH>
|
||||
<TH>Análises</TH>
|
||||
<TH>Tokens de entrada</TH>
|
||||
<TH>Tokens de saída</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{usage.map((u) => (
|
||||
<TR key={u.tenantId}>
|
||||
<TD className="text-foreground">{u.legalName}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{formatDuration(u.usage.transcriptionSeconds)}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.analysisRequests)}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.inputTokens)}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.outputTokens)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,28 @@ export const PLATFORM_NAV: NavSection[] = [
|
||||
{
|
||||
label: "IA",
|
||||
icon: Sparkles,
|
||||
children: [{ label: "Providers" }, { label: "Modelos" }, { label: "Uso" }, { label: "Custos" }],
|
||||
children: [
|
||||
{
|
||||
label: "Providers",
|
||||
href: "/platform/ia/providers",
|
||||
description: "Providers de IA globais, visíveis por qualquer tenant",
|
||||
},
|
||||
{
|
||||
label: "Modelos",
|
||||
href: "/platform/ia/modelos",
|
||||
description: "Modelos dos providers globais, com custo unitário",
|
||||
},
|
||||
{
|
||||
label: "Uso",
|
||||
href: "/platform/ia/uso",
|
||||
description: "Uso bruto de IA de todos os tenants no mês corrente",
|
||||
},
|
||||
{
|
||||
label: "Custos",
|
||||
href: "/platform/ia/custos",
|
||||
description: "Uso × custo unitário — estimado, nunca um número fixo",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sistema",
|
||||
|
||||
@@ -101,6 +101,19 @@ export interface FreeswitchNodes {
|
||||
gateways: unknown;
|
||||
}
|
||||
|
||||
export interface TenantAiUsage {
|
||||
tenantId: string;
|
||||
legalName: string;
|
||||
usage: {
|
||||
transcriptionSeconds: number;
|
||||
analysisRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
estimatedCost: number | null;
|
||||
costIncomplete: boolean;
|
||||
}
|
||||
|
||||
export interface QuotaItem {
|
||||
key: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user