nova implementacao modo chat e agente no Parecer
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain, FileText, Layers } from "lucide-react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, FileText, Layers } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
@@ -7,11 +7,11 @@ import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas") => void;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas") => void;
|
||||
}
|
||||
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas";
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
@@ -22,7 +22,6 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
||||
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
||||
{ id: "prompts" as TabType, label: "Prompts", icon: FileText },
|
||||
{ id: "parecerJuridico" as TabType, label: "Parecer Jurídico", icon: Bot },
|
||||
{ id: "areas" as TabType, label: "Áreas", icon: Layers },
|
||||
|
||||
@@ -10,21 +10,26 @@ import {
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -37,7 +42,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import { parecerService, type ParecerChatHistoricoItem, type ParecerDetalhe } from "@/services/parecerApi";
|
||||
import { parecerService, type ParecerChatHistoricoItem, type ParecerDetalhe, type ParecerChatModo } from "@/services/parecerApi";
|
||||
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
|
||||
import { playSuccessSound } from "@/utils/sound";
|
||||
import { toast } from "sonner";
|
||||
@@ -62,6 +67,7 @@ interface ChatMessage {
|
||||
content: string;
|
||||
created_at?: string;
|
||||
user_email?: string;
|
||||
tipo_resposta?: "chat" | "agente" | null;
|
||||
}
|
||||
|
||||
export function ParecerJuridicoDetailView() {
|
||||
@@ -79,9 +85,16 @@ export function ParecerJuridicoDetailView() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { areaItems } = usePrompts();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editTitulo, setEditTitulo] = useState("");
|
||||
const [editAreaId, setEditAreaId] = useState("");
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [chatLoading, setChatLoading] = useState(true);
|
||||
const [chatProcessing, setChatProcessing] = useState(false);
|
||||
const [chatInlineLoading, setChatInlineLoading] = useState(false);
|
||||
const [modo, setModo] = useState<ParecerChatModo>("agente");
|
||||
const [conteudoAtualizadoChat, setConteudoAtualizadoChat] = useState<string | null>(null);
|
||||
const [historicoOpen, setHistoricoOpen] = useState(false);
|
||||
const [historicoLoading, setHistoricoLoading] = useState(false);
|
||||
@@ -141,6 +154,7 @@ export function ParecerJuridicoDetailView() {
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
user_email: m.user_email,
|
||||
tipo_resposta: m.tipo_resposta ?? null,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
@@ -241,6 +255,41 @@ export function ParecerJuridicoDetailView() {
|
||||
}
|
||||
};
|
||||
|
||||
const abrirModalEdicao = () => {
|
||||
if (!parecer) return;
|
||||
setEditTitulo(parecer.titulo ?? "");
|
||||
setEditAreaId(parecer.area_id ?? "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSalvarEdicao = async () => {
|
||||
if (!id?.trim()) return;
|
||||
const tituloTrim = editTitulo.trim();
|
||||
const areaTrim = editAreaId.trim();
|
||||
|
||||
if (!tituloTrim || !areaTrim) {
|
||||
toast.error("Título e área são obrigatórios.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditSaving(true);
|
||||
try {
|
||||
await parecerService.editarTituloArea(id, { titulo: tituloTrim, area_id: areaTrim });
|
||||
const updated = await parecerService.buscarPorId(id);
|
||||
setParecer(updated);
|
||||
setConteudoAtualizadoChat(null);
|
||||
setEditOpen(false);
|
||||
playSuccessSound();
|
||||
toast.success("Parecer atualizado.");
|
||||
} catch (e: unknown) {
|
||||
const message =
|
||||
e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Erro ao atualizar parecer.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluirParecer = () => {
|
||||
if (!id?.trim()) return;
|
||||
setDeleting(true);
|
||||
@@ -283,25 +332,51 @@ export function ParecerJuridicoDetailView() {
|
||||
const handleSendMessage = () => {
|
||||
const text = chatInput.trim();
|
||||
if (!text || !id?.trim() || chatProcessing) return;
|
||||
const isChatMode = modo === "chat";
|
||||
const tinhaParecer = parecer != null;
|
||||
setChatInput("");
|
||||
if (isChatMode) {
|
||||
setChatInlineLoading(true);
|
||||
// Garante que o usuário veja imediatamente o loading no final do chat.
|
||||
requestAnimationFrame(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
}
|
||||
setChatProcessing(true);
|
||||
parecerService
|
||||
.enviarMensagemChat(id, text)
|
||||
.enviarMensagemChat(id, text, modo)
|
||||
.then(async (res) => {
|
||||
if (tinhaParecer) {
|
||||
setParecer((p) => (p ? { ...p, conteudo_gerado: res.conteudo_gerado } : p));
|
||||
} else {
|
||||
setConteudoAtualizadoChat(res.conteudo_gerado);
|
||||
if (!isChatMode && "conteudo_gerado" in res) {
|
||||
if (tinhaParecer) {
|
||||
setParecer((p) => (p ? { ...p, conteudo_gerado: res.conteudo_gerado } : p));
|
||||
} else {
|
||||
setConteudoAtualizadoChat(res.conteudo_gerado);
|
||||
}
|
||||
}
|
||||
await carregarChat(true);
|
||||
|
||||
if (!isChatMode) {
|
||||
// Garante que o modal de detalhes reflita também metadados atualizados
|
||||
// (ex.: "Atualizado em"), não só o conteúdo retornado no chat.
|
||||
try {
|
||||
const updatedParecer = await parecerService.buscarPorId(id);
|
||||
setParecer(updatedParecer);
|
||||
setConteudoAtualizadoChat(null);
|
||||
} catch {
|
||||
// Não bloqueia o fluxo do chat se a recarga falhar.
|
||||
}
|
||||
}
|
||||
|
||||
playSuccessSound();
|
||||
toast.success("Parecer atualizado conforme sua solicitação.");
|
||||
toast.success(isChatMode ? "Resposta enviada ao chat." : "Parecer atualizado conforme sua solicitação.");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao enviar mensagem.");
|
||||
})
|
||||
.finally(() => setChatProcessing(false));
|
||||
.finally(() => {
|
||||
setChatProcessing(false);
|
||||
setChatInlineLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!id?.trim()) {
|
||||
@@ -350,16 +425,27 @@ export function ParecerJuridicoDetailView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{chatProcessing && (
|
||||
{chatProcessing && modo === "agente" && (
|
||||
<ParecerJuridicoGeneratingScreen message="Processando sua solicitação no chat. Isso pode levar vários minutos. Mantenha esta tela aberta até finalizar." />
|
||||
)}
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg md:text-xl font-bold truncate">Parecer: {titulo}</h1>
|
||||
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!parecer || !id || editSaving}
|
||||
onClick={abrirModalEdicao}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Editar
|
||||
</Button>
|
||||
{loading && hasPreview && (
|
||||
<Loader2 className="w-4 h-4 shrink-0 animate-spin text-muted-foreground" aria-label="Sincronizando" />
|
||||
)}
|
||||
@@ -465,6 +551,67 @@ export function ParecerJuridicoDetailView() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar título e área</DialogTitle>
|
||||
<DialogDescription>Atualize os campos obrigatórios do parecer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-titulo" className="flex flex-wrap items-baseline gap-1">
|
||||
<span>Título</span>
|
||||
<span className="text-destructive font-semibold" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">obrigatório</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-titulo"
|
||||
value={editTitulo}
|
||||
onChange={(e) => setEditTitulo(e.target.value)}
|
||||
placeholder="Título do parecer"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-area" className="flex flex-wrap items-baseline gap-1">
|
||||
<span>Área</span>
|
||||
<span className="text-destructive font-semibold" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">obrigatório</span>
|
||||
</Label>
|
||||
<Select value={editAreaId || "none"} onValueChange={(v) => setEditAreaId(v === "none" ? "" : v)}>
|
||||
<SelectTrigger id="edit-area">
|
||||
<SelectValue placeholder="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>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={editSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSalvarEdicao()} disabled={editSaving || !editTitulo.trim() || !editAreaId.trim()}>
|
||||
{editSaving ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => !deleting && setDeleteOpen(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -605,13 +752,19 @@ export function ParecerJuridicoDetailView() {
|
||||
return (
|
||||
<div key={msg.id} className={`flex flex-col gap-1 ${msg.role === "user" ? "items-end" : "items-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap break-words ${
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm break-words ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-br-md"
|
||||
? "bg-primary text-primary-foreground rounded-br-md whitespace-pre-wrap"
|
||||
: "bg-card border border-border text-card-foreground rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.role === "assistant" ? (
|
||||
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-p:leading-relaxed">
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-1 ${msg.role === "user" ? "flex-row-reverse justify-end" : "flex-row"}`}
|
||||
@@ -639,7 +792,19 @@ export function ParecerJuridicoDetailView() {
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{msg.role === "assistant" && (
|
||||
{msg.role === "assistant" && msg.tipo_resposta && (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center gap-0.5 rounded-md border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
msg.tipo_resposta === "chat"
|
||||
? "bg-primary/10 border-primary/30 text-primary"
|
||||
: "bg-emerald-500/10 border-emerald-500/30 text-emerald-700 dark:text-emerald-400",
|
||||
].join(" ")}
|
||||
>
|
||||
{msg.tipo_resposta}
|
||||
</span>
|
||||
)}
|
||||
{msg.role === "assistant" && msg.tipo_resposta === "agente" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -658,10 +823,35 @@ export function ParecerJuridicoDetailView() {
|
||||
</div>
|
||||
</div>
|
||||
); })}
|
||||
{!chatLoading && modo === "chat" && chatInlineLoading && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Gerando resposta no chat...
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} aria-hidden className="h-0 w-full shrink-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="p-3 border-t border-border space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Modo</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modo === "chat" ? "Apenas responde no chat" : "Agente (atualiza o parecer)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Chat</span>
|
||||
<Switch
|
||||
checked={modo === "agente"}
|
||||
onCheckedChange={(checked) => setModo(checked ? "agente" : "chat")}
|
||||
disabled={chatProcessing}
|
||||
aria-label="Alternar modo de resposta"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Agente</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 md:gap-2 items-end">
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user