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
316 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|