Files
pagodera/components/admin/items/ItemAiAssistant.tsx
T
Evandro Fonseca Junior d3704602fd feat(items): add server field and improve item management
refactor(ai): enhance item structure generation with schema validation
feat(ai): add token usage tracking and cost calculation
fix(ui): improve item card number formatting with locale support
docs(items): add documentation for item value separation
test(items): add tests for value field validation
2025-11-11 03:04:49 -03:00

316 lines
12 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture";
interface ItemAiAssistantProps {
locale: string;
categories: string[];
serverOptions: string[];
onGenerated: (result: { json: unknown; prefill: ItemAiPrefill }) => void;
}
export interface ItemAiPrefill {
name?: string;
description?: string;
category?: string;
rarity?: string | null;
servers?: string[] | string;
tags?: string[] | string;
metadata?: Record<string, unknown>;
}
export function ItemAiAssistant({
locale,
categories,
serverOptions,
onGenerated,
}: ItemAiAssistantProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [scope, setScope] = useState<"global" | "server">("global");
const [server, setServer] = useState<string>(serverOptions[0] ?? "");
const [images, setImages] = useState<ProcessedImage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJson, setRawJson] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
// Habilita geração se houver ao menos uma imagem válida OU nome+descrição preenchidos
const canGenerate = useMemo(() => {
const hasValidImage = images.length > 0;
const hasTextInputs = name.trim() && description.trim();
return Boolean(hasValidImage || hasTextInputs);
}, [images.length, name, description]);
const disabled = useMemo(() => !canGenerate, [canGenerate]);
const handleGenerate = useCallback(async () => {
if (disabled || loading) return;
setLoading(true);
setError(null);
setLogs((prev) => [...prev, "Iniciando geração com IA..."]);
try {
setLogs((prev) => [
...prev,
`Payload: nome='${name.trim()}', escopo='${scope}', imagens=${images.length}, servidor='${scope === "server" ? server : "(global)"}'`,
]);
const response = await fetch("/api/admin/items/analyze", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
locale,
name,
description,
scope,
server: scope === "server" ? server : null,
categories,
servers: serverOptions,
images: images.map((image) => image.dataUrl),
}),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.error || "Falha na análise com IA.");
}
const data = await response.json();
const structure = data?.structure ?? data;
const columns = structure?.columns;
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
if (!Array.isArray(imgs) || imgs.length === 0) return [];
const first = imgs[0];
const base = first.originalName
.replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase()
.replace(/[_\s]+/g, "-");
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
const orientation = first.height > first.width ? "retrato" : "paisagem";
const suggestions: string[] = [];
if (base && !common.has(base)) suggestions.push(base);
suggestions.push(`${orientation}-1`);
suggestions.push("foto-1");
return Array.from(new Set(suggestions)).slice(0, 3);
};
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
// Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = {
...structure,
columns: {
...columns,
origem_dados: origemDados,
confirmar_nome: autoNameDetected ? false : undefined,
mensagens: {
obrigatorio: ["nome"],
dicas: autoNameDetected
? "Confirme o nome sugerido ou ajuste se necessário."
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
},
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
},
};
setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = {
name: autoNameDetected ? (columns!.name as string) : undefined,
description: autoDescDetected ? (columns!.description as string) : undefined,
metadata: typeof columns === "object" && columns !== null
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
};
if (columns) {
if (typeof columns.category === "string") {
prefill.category = columns.category;
}
if (Array.isArray(columns.servers)) {
prefill.servers = columns.servers as string[];
}
if (typeof columns.servers === "string") {
prefill.servers = columns.servers;
}
if (Array.isArray(columns.tags)) {
prefill.tags = columns.tags as string[];
}
if (typeof columns.tags === "string") {
prefill.tags = columns.tags;
}
if (columns.rarity && typeof columns.rarity === "string") {
prefill.rarity = columns.rarity;
}
}
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
onGenerated({ json: finalStructure, prefill });
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
} finally {
setLoading(false);
setLogs((prev) => [...prev, "Processo de geração concluído."]);
}
}, [
categories,
description,
disabled,
images,
locale,
loading,
name,
onGenerated,
scope,
server,
serverOptions,
]);
return (
<section className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h2 className="text-2xl font-semibold flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Assistente IA para registro de item
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e metadados. Você pode ajustar os dados
antes de salvar.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
Até 2 imagens · Compressão automática · Suporte a Ctrl+V
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1.1fr,0.9fr]">
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Nome do item</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Ex.: Final Fate"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Escopo</label>
<Select value={scope} onValueChange={(value: "global" | "server") => setScope(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="server">Específico de servidor</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scope === "server" && (
<div className="space-y-2">
<label className="text-sm font-medium">Servidor alvo</label>
<Select value={server} onValueChange={setServer}>
<SelectTrigger>
<SelectValue placeholder="Selecione o servidor" />
</SelectTrigger>
<SelectContent>
{serverOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Descrição detalhada para a IA</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Cole aqui todo o texto do item (atributos, requisitos, efeitos, etc.)"
rows={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Prints / evidências visuais</label>
<ItemImageCapture onImagesChange={setImages} />
{images.length > 0 && (
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
)}
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleGenerate} disabled={disabled || loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gerar com IA
</Button>
<p className="text-xs text-muted-foreground">
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
</p>
</div>
</div>
<Card className="min-h-full">
<CardHeader>
<CardTitle className="text-sm">Prévia da resposta</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-xs text-muted-foreground">
{rawJson ? (
<pre className="max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3">
{rawJson}
</pre>
) : (
<p>
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os
campos do formulário.
</p>
)}
{logs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
<div className="rounded-md border bg-muted/30 p-2">
{logs.map((line, idx) => (
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</section>
);
}