394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
import React, { useState, useEffect, useRef, forwardRef } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { ArrowLeft, ArrowRight, Sparkles, User } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
|
import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
|
|
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
|
import { cn } from "@/lib/utils";
|
|
import { usePrompts } from "@/contexts/PromptsContext";
|
|
import { areasService, type AreaItem } from "@/services/areas";
|
|
import { PromptGeneratingScreen } from "@/components/prompts/PromptGeneratingScreen";
|
|
import { assistentePromptsService } from "@/services/assistentePrompts";
|
|
import { promptsService } from "@/services/promptsApi";
|
|
import { toast } from "sonner";
|
|
import { playSuccessSound } from "@/utils/sound";
|
|
|
|
export function PromptsFormView() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { prompts, setPrompts } = usePrompts();
|
|
const isEdit = Boolean(id);
|
|
|
|
const prompt = id ? prompts.find((p) => p.id === id) : null;
|
|
|
|
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
|
|
const [areasLoading, setAreasLoading] = useState(true);
|
|
|
|
const [titulo, setTitulo] = useState("");
|
|
const [descricao, setDescricao] = useState("");
|
|
const [areaId, setAreaId] = useState("");
|
|
const [conteudo, setConteudo] = useState("");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
|
|
const [assistantMode, setAssistantMode] = useState<"create" | "refine">("create");
|
|
const [assistantInstructions, setAssistantInstructions] = useState("");
|
|
const [isAssistantLoading, setIsAssistantLoading] = useState(false);
|
|
const [isAssistantWorkingInBackground, setIsAssistantWorkingInBackground] = useState(false);
|
|
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
|
|
|
|
const confettiRef = useRef<ConfettiRef>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const hasContent = conteudo.trim().length > 0;
|
|
|
|
const effectiveAreaId = areaId && areaItems.some((a) => a.id === areaId) ? areaId : (areaItems[0]?.id ?? "");
|
|
const effectiveAreaName = areaItems.find((a) => a.id === effectiveAreaId)?.nome ?? "";
|
|
|
|
useEffect(() => {
|
|
setAreasLoading(true);
|
|
areasService.listarTotal()
|
|
.then((data) => setAreaItems(data))
|
|
.catch(() => setAreaItems([]))
|
|
.finally(() => setAreasLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (prompt) {
|
|
setTitulo(prompt.titulo);
|
|
setDescricao(prompt.descricao ?? "");
|
|
setAreaId(prompt.area_id ?? "");
|
|
setConteudo(prompt.conteudo ?? "");
|
|
} else if (!id) {
|
|
setTitulo("");
|
|
setDescricao("");
|
|
setAreaId("");
|
|
setConteudo("");
|
|
}
|
|
}, [prompt, id]);
|
|
|
|
useEffect(() => {
|
|
if (!id && areaItems.length > 0 && !areaId) {
|
|
setAreaId(areaItems[0].id);
|
|
}
|
|
}, [id, areaItems, areaId]);
|
|
|
|
useEffect(() => {
|
|
if (id && !prompt && prompts.length > 0) {
|
|
navigate("/commander/prompts", { replace: true });
|
|
}
|
|
}, [id, prompt, prompts.length, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!conteudo.trim() && assistantMode === "refine") {
|
|
setAssistantMode("create");
|
|
}
|
|
}, [conteudo, assistantMode]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
abortControllerRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
const finalTitulo = titulo.trim();
|
|
const finalAreaId = effectiveAreaId;
|
|
const finalConteudo = conteudo.trim();
|
|
if (!finalTitulo || !finalAreaId) {
|
|
toast.error("Preencha o título e a área.");
|
|
return;
|
|
}
|
|
if (!finalConteudo) {
|
|
toast.error("Preencha o conteúdo do prompt.");
|
|
return;
|
|
}
|
|
const finalAreaName = areaItems.find((a) => a.id === finalAreaId)?.nome ?? "";
|
|
const finalDescricao = descricao.trim();
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
if (isEdit && prompt) {
|
|
const res = await promptsService.editar(prompt.id, {
|
|
titulo: finalTitulo,
|
|
descricao: finalDescricao,
|
|
area_id: finalAreaId,
|
|
conteudo: finalConteudo,
|
|
});
|
|
setPrompts((prev) =>
|
|
prev.map((p) =>
|
|
p.id === prompt.id
|
|
? { ...p, id: res.id, titulo: res.titulo, area: finalAreaName, area_id: res.area_id, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }
|
|
: p
|
|
)
|
|
);
|
|
toast.success("Prompt atualizado.");
|
|
} else {
|
|
const res = await promptsService.criar({
|
|
titulo: finalTitulo,
|
|
descricao: finalDescricao,
|
|
area_id: finalAreaId,
|
|
conteudo: finalConteudo,
|
|
});
|
|
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
|
|
toast.success("Prompt criado.");
|
|
}
|
|
navigate("/commander/prompts");
|
|
} catch (e: unknown) {
|
|
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAssistantSubmit = () => {
|
|
if (!assistantInstructions.trim()) {
|
|
toast.error("Preencha as instruções.");
|
|
return;
|
|
}
|
|
if (assistantMode === "refine" && !conteudo.trim()) {
|
|
toast.error("Não há conteúdo para melhorar.");
|
|
return;
|
|
}
|
|
|
|
setIsAssistantOpen(false);
|
|
setShowGeneratingModal(true);
|
|
setIsAssistantWorkingInBackground(true);
|
|
setIsAssistantLoading(true);
|
|
|
|
const ac = new AbortController();
|
|
abortControllerRef.current = ac;
|
|
const signal = ac.signal;
|
|
|
|
const onSuccess = (text: string) => {
|
|
setConteudo(text);
|
|
confettiRef.current?.fire({});
|
|
playSuccessSound();
|
|
const msg =
|
|
assistantMode === "create" ? "Modelo inicial gerado!" : "Conteúdo atualizado!";
|
|
toast.success(msg, {
|
|
className: "bg-green-600 border-green-700 text-white",
|
|
});
|
|
};
|
|
|
|
const onFinish = () => {
|
|
setShowGeneratingModal(false);
|
|
setIsAssistantLoading(false);
|
|
setIsAssistantWorkingInBackground(false);
|
|
abortControllerRef.current = null;
|
|
};
|
|
|
|
const onError = (e: unknown) => {
|
|
const isAborted =
|
|
(e && typeof e === "object" && "code" in e && (e as { code: string }).code === "ERR_CANCELED") ||
|
|
(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
|
|
if (!isAborted) {
|
|
const message =
|
|
e && typeof e === "object" && "message" in e
|
|
? (e as { message: string }).message
|
|
: "Erro ao processar.";
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
if (assistantMode === "create") {
|
|
assistentePromptsService.gerar(assistantInstructions, signal)
|
|
.then(onSuccess)
|
|
.catch(onError)
|
|
.finally(onFinish);
|
|
} else {
|
|
assistentePromptsService.refinar(conteudo, assistantInstructions, signal)
|
|
.then(onSuccess)
|
|
.catch(onError)
|
|
.finally(onFinish);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0 relative">
|
|
<Confetti
|
|
ref={confettiRef}
|
|
manualstart
|
|
className="absolute inset-0 size-full pointer-events-none z-50"
|
|
/>
|
|
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
|
|
<div className="flex flex-col gap-3">
|
|
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Voltar
|
|
</Button>
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div className="flex-1 min-w-[200px] space-y-2">
|
|
<Label htmlFor="form-titulo">Título</Label>
|
|
<Input
|
|
id="form-titulo"
|
|
value={titulo}
|
|
onChange={(e) => setTitulo(e.target.value)}
|
|
placeholder="Ex: Prompt para análise de documentos"
|
|
/>
|
|
</div>
|
|
<div className="w-full sm:w-[220px] space-y-2">
|
|
<Label htmlFor="form-area">Área</Label>
|
|
<Select value={effectiveAreaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
|
|
<SelectTrigger id="form-area">
|
|
<SelectValue placeholder={areasLoading ? "Carregando áreas..." : "Selecione a área"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Selecione a área</SelectItem>
|
|
{areaItems.map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>
|
|
{a.nome}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="form-descricao">Descrição <span className="text-muted-foreground font-normal">(opcional)</span></Label>
|
|
<Input
|
|
id="form-descricao"
|
|
value={descricao}
|
|
onChange={(e) => setDescricao(e.target.value)}
|
|
placeholder="Ex: Breve descrição do prompt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col min-h-0 p-3 md:p-6 gap-2">
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<Label htmlFor="form-conteudo">Conteúdo do Prompt</Label>
|
|
<div className="flex items-center gap-2">
|
|
{isAssistantWorkingInBackground && (
|
|
<div
|
|
className="w-2 h-2 rounded-full bg-primary animate-pulse-glow shrink-0"
|
|
title="Gerando em andamento"
|
|
/>
|
|
)}
|
|
<PromptAssistantButton
|
|
onClick={() => setIsAssistantOpen(true)}
|
|
disabled={isAssistantWorkingInBackground}
|
|
loading={isAssistantWorkingInBackground}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Textarea
|
|
id="form-conteudo"
|
|
value={conteudo}
|
|
onChange={(e) => setConteudo(e.target.value)}
|
|
placeholder="Digite o conteúdo do prompt..."
|
|
className="flex-1 min-h-[200px] resize-none p-3"
|
|
/>
|
|
<div className="flex justify-end pt-2">
|
|
<InteractiveHoverButton
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!titulo.trim() || !effectiveAreaId || !conteudo.trim() || isSaving}
|
|
className="disabled:opacity-50 disabled:pointer-events-none"
|
|
>
|
|
{isSaving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar prompt"}
|
|
</InteractiveHoverButton>
|
|
</div>
|
|
</div>
|
|
|
|
{showGeneratingModal && (
|
|
<PromptGeneratingScreen
|
|
mode={assistantMode}
|
|
message="Está em processamento. Aguarde que em breve o prompt ficará pronto. O modal será fechado assim que finalizar."
|
|
/>
|
|
)}
|
|
|
|
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col gap-6 overflow-hidden rounded-xl border border-border/80 bg-card p-8 shadow-modal">
|
|
<DialogHeader className="space-y-4">
|
|
<div className="flex justify-center">
|
|
<PromptAssistantButton asBadge />
|
|
</div>
|
|
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
|
<DialogDescription className="text-base text-muted-foreground">
|
|
Escolha criar um novo prompt do zero ou melhorar o conteúdo atual. Preencha as instruções e o assistente gerará o texto.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-8 overflow-y-auto flex-1 min-h-0 px-1 pt-1 pb-2 pr-3 relative z-10">
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-medium">O que deseja fazer?</Label>
|
|
<RadioGroup
|
|
value={assistantMode}
|
|
onValueChange={(v) => setAssistantMode(v as "create" | "refine")}
|
|
className="grid gap-4"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<RadioGroupItem value="create" id="assistant-create" />
|
|
<Label htmlFor="assistant-create" className="font-normal cursor-pointer">
|
|
Criar novo
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<RadioGroupItem
|
|
value="refine"
|
|
id="assistant-refine"
|
|
disabled={!hasContent}
|
|
/>
|
|
<Label
|
|
htmlFor="assistant-refine"
|
|
className={`font-normal ${!hasContent ? "cursor-not-allowed text-muted-foreground" : "cursor-pointer"}`}
|
|
>
|
|
Melhorar atual
|
|
{!hasContent && " (preencha o conteúdo do prompt antes)"}
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<Label htmlFor="assistant-instructions" className="text-base font-medium">
|
|
Instruções
|
|
</Label>
|
|
<Textarea
|
|
id="assistant-instructions"
|
|
value={assistantInstructions}
|
|
onChange={(e) => setAssistantInstructions(e.target.value)}
|
|
placeholder={
|
|
assistantMode === "create"
|
|
? "Ex: Um prompt que resuma reuniões em tópicos e ações..."
|
|
: "Ex: Tornar mais conciso, adicionar seção de exemplos..."
|
|
}
|
|
rows={8}
|
|
className="resize-none text-base py-4 px-4 min-h-[180px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-3 pt-4 relative z-10">
|
|
<Button variant="outline" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
|
|
Fechar
|
|
</Button>
|
|
<Button
|
|
onClick={handleAssistantSubmit}
|
|
disabled={
|
|
!assistantInstructions.trim() ||
|
|
isAssistantLoading ||
|
|
(assistantMode === "refine" && !hasContent)
|
|
}
|
|
className="gap-2"
|
|
>
|
|
<Sparkles className="w-4 h-4 shrink-0" />
|
|
{assistantMode === "create" ? "Gerar" : "Melhorar"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|