5 Commits

Author SHA1 Message Date
luisfepsale c4b281abd5 [Correçã de CORs nos arquivos] 2025-10-28 11:31:54 -03:00
luisfepsale 45ea53b364 [Parecer Juridico] 2025-10-28 09:56:35 -03:00
luisfepsale 9f75feb69a correções e bugs 2025-10-24 12:05:27 -03:00
luisfepsale a85465e332 Integração com o banco de dados 2025-10-23 23:27:28 -03:00
developer1054 8a9096470f Merge pull request 'develop' (#1) from develop into main
Reviewed-on: https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API/pulls/1
2025-10-23 17:02:15 -03:00
15 changed files with 2944 additions and 917 deletions
+47 -20
View File
@@ -1,4 +1,4 @@
import { ChevronDown, UserCog } from "lucide-react";
import { ChevronDown, UserCog, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -15,7 +15,11 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
import { useState, useEffect } from "react";
import { fetchModelsFromDatabase, ModelConfig } from "@/config/models";
// Texto padrão da personalidade (mesmo valor usado em ChatView)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface ChatHeaderProps {
showModelSelector?: boolean;
@@ -29,25 +33,30 @@ export const ChatHeader = ({
showModelSelector = false,
selectedModel = "GPT-4o",
onModelChange,
systemPrompt = "Você é um assistente útil e prestativo.",
systemPrompt = DEFAULT_SYSTEM_PROMPT,
onSystemPromptChange
}: ChatHeaderProps) => {
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
const [models, setModels] = useState<ModelConfig[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const models = [
"GPT-4.1",
"GPT-4o",
"GPT-5 Mini",
"Gemini 2.0 Flash",
"Claude Sonnet 4.5",
"DeepSeek V3.2 Chat",
"DeepSeek V3.2 Reasoner",
"Gemini 2.5 Flash",
"Gemini 2.5 Flash-Lite",
"Claude Haiku 4.5",
"GPT-4o Mini",
];
// Carrega modelos da API ao montar o componente
useEffect(() => {
const loadModels = async () => {
setIsLoadingModels(true);
try {
const modelsFromAPI = await fetchModelsFromDatabase();
setModels(modelsFromAPI);
} catch (error) {
console.error('Erro ao carregar modelos:', error);
} finally {
setIsLoadingModels(false);
}
};
loadModels();
}, []);
const handleSavePersonality = () => {
if (onSystemPromptChange) {
@@ -67,19 +76,37 @@ export const ChatHeader = ({
{showModelSelector && onModelChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-1 md:gap-2 glass-effect text-xs md:text-sm">
<Button
variant="outline"
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
disabled={isLoadingModels}
>
{isLoadingModels ? (
<>
<Loader2 className="w-3 h-3 md:w-4 md:h-4 animate-spin" />
<span className="font-medium">Carregando...</span>
</>
) : (
<>
<span className="font-medium truncate max-w-[80px] md:max-w-none">{selectedModel}</span>
<ChevronDown className="w-3 h-3 md:w-4 md:h-4" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
{models.map((model) => (
<DropdownMenuItem
key={model}
key={model.id}
className="cursor-pointer"
onClick={() => onModelChange(model)}
onClick={() => onModelChange(model.name)}
>
{model}
<div className="flex flex-col">
<span className="font-medium">{model.name}</span>
{model.description && (
<span className="text-xs text-muted-foreground">{model.description}</span>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
+65 -66
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Search, FileText } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -12,77 +12,70 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { LegalOpinion } from "./AgentView";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
interface AgentSearchProps {
onSelectOpinion: (opinion: LegalOpinion) => void;
onSelectOpinion: (opinion: OpinionRecord) => void;
}
// Simulação de base de pareceres
const mockOpinionsDatabase: LegalOpinion[] = [
{
id: "base-1",
title: "Análise Contratual - Prestação de Serviços Continuados",
content: "Parecer completo sobre prestação de serviços...",
createdAt: new Date("2024-01-15"),
category: "Direito Civil",
},
{
id: "base-2",
title: "Rescisão de Contrato de Trabalho - Justa Causa",
content: "Análise jurídica sobre rescisão contratual...",
createdAt: new Date("2024-02-20"),
category: "Direito Trabalhista",
},
{
id: "base-3",
title: "Responsabilidade Civil - Acidente de Trânsito",
content: "Parecer sobre responsabilidade civil em acidentes...",
createdAt: new Date("2024-03-10"),
category: "Direito Civil",
},
{
id: "base-4",
title: "Dissolução de Sociedade - Procedimentos e Requisitos",
content: "Análise completa sobre dissolução societária...",
createdAt: new Date("2024-01-25"),
category: "Direito Empresarial",
},
{
id: "base-5",
title: "Direitos do Consumidor - Vícios em Produtos",
content: "Parecer sobre direitos do consumidor e garantias...",
createdAt: new Date("2024-02-05"),
category: "Direito do Consumidor",
},
];
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]);
const [searchResults, setSearchResults] = useState<OpinionRecord[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(5);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleSearch = () => {
// Busca pareceres da API
const handleSearch = async () => {
setIsLoading(true);
try {
const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
setSearchResults(data);
} catch (error: any) {
console.error('Erro ao buscar pareceres:', error);
toast({
title: "Erro ao buscar pareceres",
description: error.message || "Não foi possível buscar os pareceres.",
variant: "destructive",
});
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// Busca automaticamente quando a página ou itemsPerPage mudam
useEffect(() => {
if (searchTerm.trim()) {
handleSearch();
}
}, [currentPage, itemsPerPage]);
// Debounce para busca automática
useEffect(() => {
if (!searchTerm.trim()) {
setSearchResults([]);
return;
}
const results = mockOpinionsDatabase.filter(
(opinion) =>
opinion.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
opinion.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
opinion.category?.toLowerCase().includes(searchTerm.toLowerCase())
);
const timer = setTimeout(() => {
if (currentPage === 1) {
handleSearch();
} else {
setCurrentPage(1); // Volta para primeira página ao buscar
}
}, 500);
setSearchResults(results);
setCurrentPage(1);
};
return () => clearTimeout(timer);
}, [searchTerm]);
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedResults = searchResults.slice(startIndex, startIndex + itemsPerPage);
return (
<div className="flex flex-col h-full bg-background">
@@ -92,24 +85,30 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Digite título, frase ou categoria..."
placeholder="Digite título ou categoria..."
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} className="gap-2">
<Button onClick={handleSearch} className="gap-2" disabled={isLoading}>
<Search className="w-4 h-4" />
Buscar
{isLoading ? 'Buscando...' : 'Buscar'}
</Button>
</div>
{searchResults.length > 0 && (
<p className="text-sm text-muted-foreground">
Base com {mockOpinionsDatabase.length} pareceres disponíveis
{searchResults.length} {searchResults.length === 1 ? 'parecer encontrado' : 'pareceres encontrados'}
</p>
)}
</div>
<div className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-6">
{searchResults.length > 0 ? (
{isLoading ? (
<div className="text-center text-muted-foreground py-12">
<p>Buscando pareceres...</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-3">
{paginatedResults.map((opinion) => (
{searchResults.map((opinion) => (
<div
key={opinion.id}
className="p-4 border border-border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
@@ -118,13 +117,13 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-primary mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-foreground">{opinion.title}</h3>
<h3 className="font-semibold text-foreground">{opinion.titulo}</h3>
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
<span>{opinion.category}</span>
<span>{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}</span>
<span>{opinion.categoria || '-'}</span>
<span>{new Date(opinion.created_at).toLocaleDateString('pt-BR')}</span>
</div>
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{opinion.content}
{opinion.instrucoes}
</p>
</div>
</div>
+201 -48
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus, Search, Eye, Trash2, ArrowUpDown } from "lucide-react";
import { useState, useEffect } from "react";
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -17,30 +17,84 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { OpinionDialog } from "./OpinionDialog";
import { AgentSearch } from "./AgentSearch";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
export interface LegalOpinion {
id: string;
title: string;
content: string;
createdAt: Date;
category?: string;
}
type SortField = "title" | "createdAt" | "category";
type SortField = "titulo" | "created_at" | "categoria";
type SortOrder = "asc" | "desc";
export const AgentView = () => {
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [sortField, setSortField] = useState<SortField>("createdAt");
const [sortField, setSortField] = useState<SortField>("created_at");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
const [selectedOpinion, setSelectedOpinion] = useState<OpinionRecord | null>(null);
const [showSearch, setShowSearch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [opinionToDelete, setOpinionToDelete] = useState<OpinionRecord | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
// Carrega pareceres da API
const loadOpinions = async () => {
setIsLoading(true);
try {
const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
setOpinions(data);
} catch (error: any) {
console.error('Erro ao carregar pareceres:', error);
toast({
title: "Erro ao carregar pareceres",
description: error.message || "Não foi possível carregar a lista de pareceres.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
// Carrega pareceres ao montar o componente e quando mudar página/busca
useEffect(() => {
loadOpinions();
}, [currentPage, itemsPerPage]);
// Debounce para busca
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadOpinions();
} else {
setCurrentPage(1); // Volta para a primeira página ao buscar
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
const handleSort = (field: SortField) => {
if (sortField === field) {
@@ -51,17 +105,11 @@ export const AgentView = () => {
}
};
const filteredOpinions = opinions.filter(
(op) =>
op.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
op.category?.toLowerCase().includes(searchTerm.toLowerCase())
);
const sortedOpinions = [...filteredOpinions].sort((a, b) => {
const sortedOpinions = [...opinions].sort((a, b) => {
const multiplier = sortOrder === "asc" ? 1 : -1;
if (sortField === "createdAt") {
return multiplier * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
if (sortField === "created_at") {
return multiplier * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
}
const aValue = a[sortField] || "";
@@ -70,20 +118,13 @@ export const AgentView = () => {
});
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedOpinions = sortedOpinions.slice(startIndex, startIndex + itemsPerPage);
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
setOpinions([newOpinion, ...opinions]);
setIsDialogOpen(false);
setSelectedOpinion(null);
const handleOpinionCreated = () => {
// Recarrega a lista após criar um novo parecer
loadOpinions();
};
const handleDeleteOpinion = (id: string) => {
setOpinions(opinions.filter(op => op.id !== id));
};
const handleViewOpinion = (opinion: LegalOpinion) => {
const handleViewOpinion = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setIsDialogOpen(true);
};
@@ -93,12 +134,69 @@ export const AgentView = () => {
setIsDialogOpen(true);
};
const handleSelectFromSearch = (opinion: LegalOpinion) => {
const handleSelectFromSearch = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setShowSearch(false);
setIsDialogOpen(true);
};
const handleDownloadVersion = async (opinion: OpinionRecord, version: 'v1' | 'v2') => {
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
const handleDeleteOpinion = async () => {
if (!opinionToDelete) return;
setIsDeleting(true);
try {
await agentService.deleteOpinion(opinionToDelete.id);
toast({
title: "Parecer excluído",
description: "O parecer foi excluído com sucesso.",
});
// Recarrega a lista de pareceres
await loadOpinions();
// Fecha o diálogo de confirmação
setOpinionToDelete(null);
} catch (error: any) {
toast({
title: "Erro ao excluir parecer",
description: error.message || "Não foi possível excluir o parecer.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
if (showSearch) {
return (
<div className="flex flex-col h-full">
@@ -116,7 +214,7 @@ export const AgentView = () => {
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground">Agente de Parecer Jurídico</h1>
<h1 className="text-xl md:text-3xl font-bold text-foreground">Gerador de Modelos de Parecer Jurídico</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Search className="w-3 h-3 md:w-4 md:h-4" />
@@ -169,7 +267,7 @@ export const AgentView = () => {
<TableHead className="min-w-[200px]">
<Button
variant="ghost"
onClick={() => handleSort("title")}
onClick={() => handleSort("titulo")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Título
@@ -179,7 +277,7 @@ export const AgentView = () => {
<TableHead className="hidden md:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("category")}
onClick={() => handleSort("categoria")}
className="flex items-center gap-1 font-semibold text-sm"
>
Categoria
@@ -189,7 +287,7 @@ export const AgentView = () => {
<TableHead className="hidden sm:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("createdAt")}
onClick={() => handleSort("created_at")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Data
@@ -200,7 +298,13 @@ export const AgentView = () => {
</TableRow>
</TableHeader>
<TableBody>
{paginatedOpinions.length === 0 ? (
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12 text-muted-foreground">
Carregando pareceres...
</TableCell>
</TableRow>
) : sortedOpinions.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12 text-muted-foreground">
{searchTerm
@@ -209,12 +313,12 @@ export const AgentView = () => {
</TableCell>
</TableRow>
) : (
paginatedOpinions.map((opinion) => (
sortedOpinions.map((opinion) => (
<TableRow key={opinion.id}>
<TableCell className="font-medium text-xs md:text-sm">{opinion.title}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{opinion.category || "-"}</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell>
<TableCell className="hidden sm:table-cell text-xs md:text-sm">
{new Date(opinion.createdAt).toLocaleDateString("pt-BR")}
{new Date(opinion.created_at).toLocaleDateString("pt-BR")}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
@@ -227,12 +331,40 @@ export const AgentView = () => {
>
<Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteOpinion(opinion.id)}
className="text-destructive hover:text-destructive h-7 w-7 md:h-9 md:w-9"
title="Baixar"
className="h-7 w-7 md:h-9 md:w-9"
>
<Download className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v1')}
disabled={!opinion.file_url}
>
<Download className="w-4 h-4 mr-2" />
Versão 1
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v2')}
disabled={!opinion.file_url_melhoria}
>
<Download className="w-4 h-4 mr-2" />
Versão Melhorada
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => setOpinionToDelete(opinion)}
title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
@@ -245,10 +377,10 @@ export const AgentView = () => {
</Table>
</div>
{totalPages > 1 && (
{sortedOpinions.length > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
{startIndex + 1}-{Math.min(startIndex + itemsPerPage, sortedOpinions.length)} de {sortedOpinions.length}
Mostrando {sortedOpinions.length} {sortedOpinions.length === 1 ? 'parecer' : 'pareceres'}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
@@ -307,6 +439,27 @@ export const AgentView = () => {
selectedOpinion={selectedOpinion}
onOpinionCreated={handleOpinionCreated}
/>
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir o parecer "{opinionToDelete?.titulo}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOpinion}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
+87 -73
View File
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { Download, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -14,13 +13,13 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { LegalOpinion } from "./AgentView";
import { agentService, OpinionRecord } from "@/services/agent";
interface OpinionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedOpinion: LegalOpinion | null;
onOpinionCreated: (opinion: LegalOpinion) => void;
selectedOpinion: OpinionRecord | null;
onOpinionCreated: () => void;
}
export const OpinionDialog = ({
@@ -32,23 +31,23 @@ export const OpinionDialog = ({
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [instructions, setInstructions] = useState("");
const [generatedContent, setGeneratedContent] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [createdOpinion, setCreatedOpinion] = useState<OpinionRecord | null>(null);
const { toast } = useToast();
useEffect(() => {
if (selectedOpinion) {
setTitle(selectedOpinion.title);
setCategory(selectedOpinion.category || "");
setGeneratedContent(selectedOpinion.content);
setInstructions("");
setTitle(selectedOpinion.titulo);
setCategory(selectedOpinion.categoria || "");
setInstructions(selectedOpinion.instrucoes || "");
setCreatedOpinion(selectedOpinion);
} else {
setTitle("");
setCategory("");
setInstructions("");
setGeneratedContent("");
setCreatedOpinion(null);
}
}, [selectedOpinion]);
}, [selectedOpinion, open]);
const handleGenerate = async () => {
if (!instructions.trim()) {
@@ -72,52 +71,38 @@ export const OpinionDialog = ({
setIsGenerating(true);
try {
const response = await axios.post(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/gepam/parecer-tecnico",
{
const response = await agentService.createOpinion({
titulo: title,
categoria: category,
instrucoes: instructions,
},
{
headers: {
"Content-Type": "application/json",
},
}
);
const content = response.data.parecer || response.data.content || JSON.stringify(response.data, null, 2);
setGeneratedContent(content);
setIsGenerating(false);
const newOpinion: LegalOpinion = {
id: selectedOpinion?.id || Date.now().toString(),
title: title,
content: content,
createdAt: new Date(),
category: category || undefined,
};
onOpinionCreated(newOpinion);
});
if (response.success) {
toast({
title: "Parecer gerado com sucesso!",
description: "O parecer está pronto para download.",
description: "O parecer foi criado e está disponível para download.",
});
} catch (error) {
// Chama o callback para atualizar a lista
onOpinionCreated();
// Fecha o dialog após sucesso
setIsGenerating(false);
onOpenChange(false);
// Limpa os campos
setTitle("");
setCategory("");
setInstructions("");
setCreatedOpinion(null);
} else {
throw new Error('Falha ao criar parecer');
}
} catch (error: any) {
console.error("Erro ao gerar parecer:", error);
setIsGenerating(false);
let errorMessage = "Não foi possível gerar o parecer. Tente novamente.";
if (axios.isAxiosError(error)) {
if (error.response) {
errorMessage = error.response.data?.message || `Erro do servidor: ${error.response.status}`;
} else if (error.request) {
errorMessage = "Sem resposta do servidor. Verifique sua conexão.";
}
}
const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente.";
toast({
title: "Erro ao gerar parecer",
@@ -127,22 +112,45 @@ export const OpinionDialog = ({
}
};
const handleDownloadDocx = () => {
const content = generatedContent;
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${title || "parecer"}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const handleDownloadVersion = async (version: 'v1' | 'v2') => {
if (!createdOpinion && !selectedOpinion) {
toast({
title: "Nenhum parecer disponível",
description: "Por favor, gere um parecer primeiro.",
variant: "destructive",
});
return;
}
const opinion = createdOpinion || selectedOpinion;
if (!opinion) return;
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
return (
@@ -150,11 +158,11 @@ export const OpinionDialog = ({
<DialogContent className="max-w-[95vw] md:max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>
{selectedOpinion ? "Gerar Novo Modelo do Parecer" : "Novo Parecer Jurídico"}
{selectedOpinion ? "Visualizar Parecer" : "Novo Parecer Jurídico"}
</DialogTitle>
<DialogDescription>
{selectedOpinion
? "Forneça instruções para gerar um novo modelo baseado neste parecer"
? "Visualize os detalhes do parecer e faça o download das versões disponíveis"
: "Preencha os dados e instruções para gerar um novo parecer com IA"}
</DialogDescription>
</DialogHeader>
@@ -196,6 +204,7 @@ export const OpinionDialog = ({
/>
</div>
{!selectedOpinion && (
<Button
onClick={handleGenerate}
disabled={isGenerating}
@@ -210,26 +219,31 @@ export const OpinionDialog = ({
</>
)}
</Button>
)}
{generatedContent && (
<div className="space-y-2 pt-4 border-t">
<div className="flex items-center justify-between">
<Label>Parecer Gerado</Label>
{selectedOpinion && (
<div className="space-y-3 pt-4 border-t">
<Label>Downloads Disponíveis</Label>
<div className="flex flex-col gap-2">
<Button
onClick={handleDownloadDocx}
onClick={() => handleDownloadVersion('v1')}
variant="outline"
size="sm"
className="gap-2"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url}
>
<Download className="w-4 h-4" />
Baixar DOCX
Baixar Versão 1
</Button>
<Button
onClick={() => handleDownloadVersion('v2')}
variant="outline"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url_melhoria}
>
<Download className="w-4 h-4" />
Baixar Versão Melhorada
</Button>
</div>
<ScrollArea className="h-[300px] rounded-md border p-4">
<pre className="whitespace-pre-wrap font-sans text-sm">
{generatedContent}
</pre>
</ScrollArea>
</div>
)}
</div>
+190 -71
View File
@@ -3,7 +3,7 @@ import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Mic, Play, Download, Trash2, Search } from "lucide-react";
import { Mic, Download, Trash2, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -13,33 +13,56 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { audioGenerationService, VOICE_OPTIONS, VoiceType } from "@/services/audioGeneration";
interface GeneratedAudio {
id: string;
text: string;
voice: string;
voiceLabel: string;
audioUrl: string;
timestamp: Date;
}
import { audioGenerationService, VOICE_OPTIONS, VoiceType, AudioRecord } from "@/services/audioGeneration";
export const GenerationView = () => {
const [textToSpeech, setTextToSpeech] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
const [generatedAudio, setGeneratedAudio] = useState<GeneratedAudio | null>(null);
const [audioHistory, setAudioHistory] = useState<GeneratedAudio[]>([]);
const [lastGeneratedAudio, setLastGeneratedAudio] = useState<AudioRecord | null>(null);
const [audioHistory, setAudioHistory] = useState<AudioRecord[]>([]);
const [isLoadingAudios, setIsLoadingAudios] = useState(false);
const [audioSearchQuery, setAudioSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
useEffect(() => {
const savedAudios = localStorage.getItem('audioHistory');
if (savedAudios) {
setAudioHistory(JSON.parse(savedAudios));
// Carrega áudios do banco de dados
const loadAudios = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingAudios(true);
try {
const fetchedAudios = await audioGenerationService.getAudios(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedAudios)) {
setAudioHistory(fetchedAudios);
} else {
console.warn('Resposta da API não é um array:', fetchedAudios);
setAudioHistory([]);
}
}, []);
} catch (error: any) {
console.error('Erro ao carregar áudios:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de áudios.",
variant: "destructive",
});
setAudioHistory([]);
} finally {
setIsLoadingAudios(false);
}
};
// Carrega áudios ao montar e quando a paginação mudar
useEffect(() => {
loadAudios();
}, [currentPage, perPage]);
const handleGenerateAudio = async () => {
// Valida o texto antes de enviar
@@ -66,20 +89,23 @@ export const GenerationView = () => {
if (response.success) {
console.log('URL do áudio gerado:', response.audio_url);
const audio: GeneratedAudio = {
// Cria objeto do áudio recém-gerado para exibição imediata
const newGeneratedAudio: AudioRecord = {
id: response.audio_generation_id,
text: response.message,
user_email: '',
estabelecimento_id: 0,
input_text: response.message,
model: 'tts-1',
voice: selectedVoice,
voiceLabel: VOICE_OPTIONS[selectedVoice].label,
audioUrl: response.audio_url,
timestamp: new Date(),
audio_url: response.audio_url,
duration_seconds: null,
file_size: 0,
cost_usd: '0',
created_at: new Date().toISOString(),
};
setGeneratedAudio(audio);
const newHistory = [audio, ...audioHistory].slice(0, 10);
setAudioHistory(newHistory);
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
// Salva o último áudio gerado para exibição
setLastGeneratedAudio(newGeneratedAudio);
toast({
title: "Áudio gerado com sucesso",
@@ -88,6 +114,10 @@ export const GenerationView = () => {
// Limpa o campo de texto após sucesso
setTextToSpeech("");
// Recarrega a lista de áudios (sem aguardar para não bloquear a UI)
loadAudios(1, perPage);
setCurrentPage(1);
} else {
throw new Error('Erro ao gerar áudio');
}
@@ -104,10 +134,10 @@ export const GenerationView = () => {
}
};
const handleDownloadAudio = (audio: GeneratedAudio) => {
const handleDownloadAudio = (audio: AudioRecord) => {
const a = document.createElement('a');
a.href = audio.audioUrl;
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
a.href = audio.audio_url;
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -118,24 +148,51 @@ export const GenerationView = () => {
});
};
const handleDeleteAudio = (audioId: string) => {
if (generatedAudio?.id === audioId) {
setGeneratedAudio(null);
}
const newHistory = audioHistory.filter(a => a.id !== audioId);
setAudioHistory(newHistory);
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
const handleDeleteAudio = async (audioId: string) => {
try {
const result = await audioGenerationService.deleteAudio(audioId);
if (result.success) {
toast({
title: "Áudio removido",
description: "O áudio foi removido do histórico.",
description: "O áudio foi removido com sucesso.",
});
// Se o áudio deletado for o último gerado, limpa o preview
if (lastGeneratedAudio && lastGeneratedAudio.id === audioId) {
setLastGeneratedAudio(null);
}
// Recarrega a lista de áudios
await loadAudios();
} else {
throw new Error(result.message || 'Erro ao deletar áudio');
}
} catch (error: any) {
console.error('Erro ao deletar áudio:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover o áudio.",
variant: "destructive",
});
}
};
const filteredAudios = audioHistory.filter(item =>
item.voiceLabel.toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
item.text.toLowerCase().includes(audioSearchQuery.toLowerCase())
);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredAudios = Array.isArray(audioHistory)
? audioHistory.filter(item =>
(VOICE_OPTIONS[item.voice]?.label || item.voice).toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
item.input_text.toLowerCase().includes(audioSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
@@ -222,13 +279,13 @@ export const GenerationView = () => {
)}
</div>
{generatedAudio && (
{lastGeneratedAudio && (
<div className="glass-effect rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">Áudio Gerado</h4>
<p className="text-sm text-muted-foreground">
Voz: {generatedAudio.voiceLabel}
Voz: {VOICE_OPTIONS[lastGeneratedAudio.voice]?.label || lastGeneratedAudio.voice}
</p>
</div>
<div className="flex gap-2">
@@ -236,7 +293,7 @@ export const GenerationView = () => {
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleDownloadAudio(generatedAudio)}
onClick={() => handleDownloadAudio(lastGeneratedAudio)}
>
<Download className="w-3 h-3" />
Baixar
@@ -245,7 +302,7 @@ export const GenerationView = () => {
size="sm"
variant="destructive"
className="gap-1"
onClick={() => handleDeleteAudio(generatedAudio.id)}
onClick={() => handleDeleteAudio(lastGeneratedAudio.id)}
>
<Trash2 className="w-3 h-3" />
Excluir
@@ -254,14 +311,14 @@ export const GenerationView = () => {
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm mb-3">{generatedAudio.text}</p>
<p className="text-sm mb-3">{lastGeneratedAudio.input_text}</p>
<audio
key={generatedAudio.id}
key={lastGeneratedAudio.id}
controls
className="w-full"
preload="metadata"
>
<source src={generatedAudio.audioUrl} type="audio/mpeg" />
<source src={lastGeneratedAudio.audio_url} type="audio/mpeg" />
Seu navegador não suporta o elemento de áudio.
</audio>
</div>
@@ -269,16 +326,10 @@ export const GenerationView = () => {
)}
</TabsContent>
<TabsContent value="history" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Histórico de Áudios</h3>
<p className="text-sm text-muted-foreground">
{audioHistory.length} {audioHistory.length === 1 ? 'item' : 'itens'}
</p>
</div>
<div className="relative">
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={audioSearchQuery}
@@ -287,24 +338,49 @@ export const GenerationView = () => {
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{filteredAudios.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Mic className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio no histórico'}</p>
{/* Loading State */}
{isLoadingAudios ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando áudios...</p>
</div>
) : (
<>
{filteredAudios.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<Mic className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio gerado ainda'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredAudios.map((audio) => (
<div key={audio.id} className="bg-muted/30 rounded-lg p-4 space-y-3">
<div key={audio.id} className="glass-effect rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">Voz: {audio.voiceLabel}</h4>
<h4 className="font-medium">
Voz: {VOICE_OPTIONS[audio.voice]?.label || audio.voice}
</h4>
</div>
<p className="text-xs text-muted-foreground">
{new Date(audio.timestamp).toLocaleDateString('pt-BR')} às{' '}
{new Date(audio.timestamp).toLocaleTimeString('pt-BR')}
{new Date(audio.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(audio.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
@@ -325,7 +401,7 @@ export const GenerationView = () => {
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{audio.text}
{audio.input_text}
</p>
<audio
key={audio.id}
@@ -333,13 +409,56 @@ export const GenerationView = () => {
className="w-full"
preload="metadata"
>
<source src={audio.audioUrl} type="audio/mpeg" />
<source src={audio.audio_url} type="audio/mpeg" />
</audio>
</div>
))}
</div>
)}
{/* Pagination */}
{!audioSearchQuery && audioHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={audioHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
+222 -81
View File
@@ -2,37 +2,67 @@ import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload, Download, Trash2, FileAudio, Copy, Search } from "lucide-react";
import { Upload, Download, Trash2, FileAudio, Copy, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { transcriptionService } from "@/services/transcription";
import { transcriptionService, TranscriptionRecord } from "@/services/transcription";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
interface TranscriptionResult {
id: string;
fileName: string;
text: string;
timestamp: Date;
audioUrl?: string;
}
export const TranscriptionView = () => {
const [isTranscribing, setIsTranscribing] = useState(false);
const [transcriptionResult, setTranscriptionResult] = useState<TranscriptionResult | null>(null);
const [lastTranscriptionResult, setLastTranscriptionResult] = useState<TranscriptionRecord | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionResult[]>([]);
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionRecord[]>([]);
const [isLoadingTranscriptions, setIsLoadingTranscriptions] = useState(false);
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
useEffect(() => {
const savedTranscriptions = localStorage.getItem('transcriptionHistory');
if (savedTranscriptions) {
setTranscriptionHistory(JSON.parse(savedTranscriptions));
// Carrega transcrições do banco de dados
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingTranscriptions(true);
try {
const fetchedTranscriptions = await transcriptionService.getTranscriptions(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedTranscriptions)) {
setTranscriptionHistory(fetchedTranscriptions);
} else {
console.warn('Resposta da API não é um array:', fetchedTranscriptions);
setTranscriptionHistory([]);
}
}, []);
} catch (error: any) {
console.error('Erro ao carregar transcrições:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de transcrições.",
variant: "destructive",
});
setTranscriptionHistory([]);
} finally {
setIsLoadingTranscriptions(false);
}
};
// Carrega transcrições ao montar e quando a paginação mudar
useEffect(() => {
loadTranscriptions();
}, [currentPage, perPage]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -64,24 +94,31 @@ export const TranscriptionView = () => {
// Verifica se a transcrição foi bem-sucedida
if (response.success) {
const result: TranscriptionResult = {
// Cria objeto da transcrição recém-gerada para exibição imediata
const newTranscription: TranscriptionRecord = {
id: response.transcription_id,
fileName: file.name,
text: response.message,
timestamp: new Date(),
audioUrl: response.audio_url,
user_email: '',
estabelecimento_id: 0,
audio_file_name: file.name,
audio_duration_seconds: 0,
transcription_text: response.message,
model: 'whisper-1',
audio_url: response.audio_url,
cost_usd: '0',
created_at: new Date().toISOString(),
};
setTranscriptionResult(result);
const newHistory = [result, ...transcriptionHistory].slice(0, 10);
setTranscriptionHistory(newHistory);
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
// Salva a última transcrição para exibição
setLastTranscriptionResult(newTranscription);
toast({
title: "Transcrição concluída",
description: "Seu áudio foi transcrito com sucesso!",
});
// Recarrega a lista de transcrições (sem aguardar para não bloquear a UI)
loadTranscriptions(1, perPage);
setCurrentPage(1);
} else {
throw new Error(response.message || 'Erro ao transcrever áudio');
}
@@ -99,52 +136,80 @@ export const TranscriptionView = () => {
};
const handleDeleteTranscription = () => {
setTranscriptionResult(null);
setLastTranscriptionResult(null);
setSelectedFile(null);
};
const handleDeleteTranscriptionFromHistory = (transcriptionId: string) => {
if (transcriptionResult?.id === transcriptionId) {
setTranscriptionResult(null);
}
const newHistory = transcriptionHistory.filter(t => t.id !== transcriptionId);
setTranscriptionHistory(newHistory);
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
try {
const result = await transcriptionService.deleteTranscription(transcriptionId);
if (result.success) {
toast({
title: "Transcrição removida",
description: "A transcrição foi removida do histórico.",
description: "A transcrição foi removida com sucesso.",
});
// Se a transcrição deletada for a última gerada, limpa o preview
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
setLastTranscriptionResult(null);
}
// Recarrega a lista de transcrições
await loadTranscriptions();
} else {
throw new Error(result.message || 'Erro ao deletar transcrição');
}
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a transcrição.",
variant: "destructive",
});
}
};
const handleCopyTranscription = () => {
if (transcriptionResult) {
navigator.clipboard.writeText(transcriptionResult.text);
const handleCopyTranscription = (text: string) => {
navigator.clipboard.writeText(text);
toast({
title: "Texto copiado",
description: "A transcrição foi copiada para a área de transferência",
});
}
};
const handleDownloadTranscription = () => {
if (transcriptionResult) {
const blob = new Blob([transcriptionResult.text], { type: 'text/plain' });
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transcricao_${transcriptionResult.fileName}.txt`;
a.download = `transcricao_${transcription.audio_file_name}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
toast({
title: "Download iniciado",
description: "O arquivo de transcrição está sendo baixado.",
});
};
const filteredTranscriptions = transcriptionHistory.filter(item =>
item.fileName.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
item.text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredTranscriptions = Array.isArray(transcriptionHistory)
? transcriptionHistory.filter(item =>
item.audio_file_name.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
item.transcription_text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
@@ -222,13 +287,13 @@ export const TranscriptionView = () => {
)}
</div>
{transcriptionResult && (
{lastTranscriptionResult && (
<div className="glass-effect rounded-xl p-6 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{transcriptionResult.fileName}</h4>
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
Transcrito {new Date(transcriptionResult.timestamp).toLocaleTimeString('pt-BR')}
Transcrito {new Date(lastTranscriptionResult.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
@@ -236,7 +301,7 @@ export const TranscriptionView = () => {
size="sm"
variant="outline"
className="gap-1"
onClick={handleCopyTranscription}
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
>
<Copy className="w-3 h-3" />
Copiar
@@ -245,7 +310,7 @@ export const TranscriptionView = () => {
size="sm"
variant="outline"
className="gap-1"
onClick={handleDownloadTranscription}
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
>
<Download className="w-3 h-3" />
Baixar
@@ -263,23 +328,17 @@ export const TranscriptionView = () => {
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{transcriptionResult.text}
{lastTranscriptionResult.transcription_text}
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Histórico de Transcrições</h3>
<p className="text-sm text-muted-foreground">
{transcriptionHistory.length} {transcriptionHistory.length === 1 ? 'item' : 'itens'}
</p>
</div>
<div className="relative">
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={transcriptionSearchQuery}
@@ -288,24 +347,62 @@ export const TranscriptionView = () => {
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{filteredTranscriptions.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileAudio className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}</p>
{/* Loading State */}
{isLoadingTranscriptions ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando transcrições...</p>
</div>
) : (
<div className="space-y-3">
{filteredTranscriptions.map((item) => (
<div key={item.id} className="bg-muted/30 rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.fileName}</h4>
<p className="text-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleDateString('pt-BR')} às{' '}
{new Date(item.timestamp).toLocaleTimeString('pt-BR')}
<>
{filteredTranscriptions.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredTranscriptions.map((item) => (
<div key={item.id} className="glass-effect rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
{new Date(item.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(item.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleCopyTranscription(item.transcription_text)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadTranscription(item)}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
@@ -314,14 +411,58 @@ export const TranscriptionView = () => {
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{item.text}
{item.transcription_text}
</p>
</div>
))}
</div>
)}
{/* Pagination */}
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={transcriptionHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
+147 -189
View File
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { chatService, StoredChat, StoredFolder } from "@/services/chat";
import { chatService, ChatRecord, FolderRecord } from "@/services/chat";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
@@ -38,7 +38,7 @@ interface ChatSidebarProps {
isCollapsed: boolean;
onToggleCollapse: () => void;
onNewChat: () => void;
onSelectChat?: (chat: StoredChat) => void;
onSelectChat?: (chat: ChatRecord) => void;
currentChatId?: string;
}
@@ -46,20 +46,22 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [editingFolder, setEditingFolder] = useState<StoredFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<StoredFolder | null>(null);
const [deletingChat, setDeletingChat] = useState<StoredChat | null>(null);
const [renamingFolder, setRenamingFolder] = useState<FolderRecord | null>(null);
const [renamedFolderName, setRenamedFolderName] = useState("");
const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Estado carregado do localStorage via chatService
const [chats, setChats] = useState<StoredChat[]>([]);
const [folders, setFolders] = useState<StoredFolder[]>([]);
// Estado carregado do banco de dados via chatService
const [chats, setChats] = useState<ChatRecord[]>([]);
const [folders, setFolders] = useState<FolderRecord[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Carrega chats e pastas do localStorage quando o componente monta
// Carrega chats e pastas do banco de dados quando o componente monta
useEffect(() => {
loadChatsAndFolders();
@@ -76,28 +78,39 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
};
}, []);
const loadChatsAndFolders = () => {
const loadedChats = chatService.getAllChats();
const loadedFolders = chatService.getAllFolders();
const loadChatsAndFolders = async () => {
setIsLoading(true);
try {
const data = await chatService.getChatsAndFolders();
setChats(loadedChats);
setFolders(loadedFolders);
console.log('Dados carregados:', data);
setChats(data.chats);
setFolders(data.folders);
// Expande todas as pastas por padrão
setExpandedFolders(new Set(loadedFolders.map(f => f.id)));
setExpandedFolders(new Set(data.folders.map(f => f.id)));
} catch (error: any) {
console.error('Erro ao carregar chats e pastas:', error);
toast({
title: "Erro ao carregar dados",
description: error.message || "Não foi possível carregar chats e pastas.",
variant: "destructive",
});
// Define arrays vazios em caso de erro
setChats([]);
setFolders([]);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = () => {
const handleCreateFolder = async () => {
if (newFolderName.trim()) {
try {
const newFolder: StoredFolder = {
id: chatService.generateChatId(), // Usa mesmo gerador de ID
name: newFolderName,
createdAt: new Date(),
chatIds: [],
};
chatService.saveFolder(newFolder);
loadChatsAndFolders();
await chatService.createFolder(newFolderName);
await loadChatsAndFolders();
setNewFolderName("");
setIsCreateFolderOpen(false);
@@ -105,86 +118,75 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
title: "Pasta criada",
description: `Pasta "${newFolderName}" criada com sucesso.`,
});
} catch (error) {
} catch (error: any) {
console.error('Erro ao criar pasta:', error);
toast({
title: "Erro ao criar pasta",
description: "Não foi possível criar a pasta. Tente novamente.",
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleEditFolder = () => {
if (editingFolder && newFolderName.trim()) {
try {
const updatedFolder: StoredFolder = {
...editingFolder,
name: newFolderName,
};
chatService.saveFolder(updatedFolder);
loadChatsAndFolders();
setNewFolderName("");
setEditingFolder(null);
setIsEditFolderOpen(false);
toast({
title: "Pasta renomeada",
description: `Pasta renomeada para "${newFolderName}".`,
});
} catch (error) {
toast({
title: "Erro ao renomear pasta",
description: "Não foi possível renomear a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleDeleteFolder = () => {
const handleDeleteFolder = async () => {
if (deletingFolder) {
try {
// Remove chats da pasta (volta para "Sem Pasta")
const updatedChats = chats.map(chat => {
if (chat.folderId === deletingFolder.id) {
const updated = { ...chat, folderId: undefined };
chatService.saveChat(updated);
return updated;
}
return chat;
});
chatService.deleteFolder(deletingFolder.id);
loadChatsAndFolders();
await chatService.deleteFolder(deletingFolder.id);
await loadChatsAndFolders();
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
toast({
title: "Pasta excluída",
description: "As conversas foram movidas para 'Sem Pasta'.",
description: "A pasta foi excluída com sucesso.",
});
} catch (error) {
} catch (error: any) {
console.error('Erro ao excluir pasta:', error);
toast({
title: "Erro ao excluir pasta",
description: "Não foi possível excluir a pasta. Tente novamente.",
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const openEditFolder = (folder: StoredFolder) => {
setEditingFolder(folder);
setNewFolderName(folder.name);
setIsEditFolderOpen(true);
};
const openDeleteFolder = (folder: StoredFolder) => {
const openDeleteFolder = (folder: FolderRecord) => {
setDeletingFolder(folder);
setIsDeleteFolderOpen(true);
};
const openRenameFolder = (folder: FolderRecord) => {
setRenamingFolder(folder);
setRenamedFolderName(folder.name);
setIsRenameFolderOpen(true);
};
const handleRenameFolder = async () => {
if (renamingFolder && renamedFolderName.trim()) {
try {
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
await loadChatsAndFolders();
setRenamingFolder(null);
setRenamedFolderName("");
setIsRenameFolderOpen(false);
toast({
title: "Pasta renomeada",
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
});
} catch (error: any) {
console.error('Erro ao renomear pasta:', error);
toast({
title: "Erro ao renomear pasta",
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const toggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderId)) {
@@ -195,62 +197,31 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
setExpandedFolders(newExpanded);
};
const moveToFolder = (chatId: string, folderId: string) => {
const moveToFolder = async (chatId: string, folderId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: folderId,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
await chatService.moveChatToFolder(chatId, folderId);
await loadChatsAndFolders();
const folder = folders.find(f => f.id === folderId);
toast({
title: "Chat movido",
description: `Movido para a pasta "${folder?.name}".`,
});
}
} catch (error) {
} catch (error: any) {
console.error('Erro ao mover chat:', error);
toast({
title: "Erro ao mover chat",
description: "Não foi possível mover o chat. Tente novamente.",
description: error.message || "Não foi possível mover o chat. Tente novamente.",
variant: "destructive",
});
}
};
const removeFromFolder = (chatId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: undefined,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
toast({
title: "Chat removido da pasta",
description: "Chat movido para 'Sem Pasta'.",
});
}
} catch (error) {
toast({
title: "Erro ao remover chat",
description: "Não foi possível remover o chat da pasta.",
variant: "destructive",
});
}
};
const handleDeleteChat = () => {
const handleDeleteChat = async () => {
if (deletingChat) {
try {
chatService.deleteChat(deletingChat.id);
loadChatsAndFolders();
await chatService.deleteChat(deletingChat.id);
await loadChatsAndFolders();
setDeletingChat(null);
setIsDeleteChatOpen(false);
@@ -258,22 +229,23 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
title: "Chat excluído",
description: "A conversa foi excluída com sucesso.",
});
} catch (error) {
} catch (error: any) {
console.error('Erro ao excluir chat:', error);
toast({
title: "Erro ao excluir chat",
description: "Não foi possível excluir o chat. Tente novamente.",
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
variant: "destructive",
});
}
}
};
const openDeleteChat = (chat: StoredChat) => {
const openDeleteChat = (chat: ChatRecord) => {
setDeletingChat(chat);
setIsDeleteChatOpen(true);
};
const handleSelectChat = (chat: StoredChat) => {
const handleSelectChat = (chat: ChatRecord) => {
if (onSelectChat) {
onSelectChat(chat);
}
@@ -283,20 +255,17 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
const filteredChats = chats.filter((chat) => {
const searchLower = searchQuery.toLowerCase();
const titleMatch = chat.title.toLowerCase().includes(searchLower);
const contentMatch = chat.messages.some(msg =>
msg.content.toLowerCase().includes(searchLower)
);
return titleMatch || contentMatch;
return titleMatch;
});
// Separar chats sem pasta
const chatsWithoutFolder = filteredChats.filter((c) => !c.folderId);
// Separar chats sem pasta (folder_id é null)
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
// Agrupar chats por pasta
// Agrupar chats por pasta (quando folder_id === folder.id)
const chatsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folderId === folder.id);
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
return acc;
}, {} as Record<string, StoredChat[]>);
}, {} as Record<string, ChatRecord[]>);
if (isCollapsed) {
return (
@@ -382,33 +351,34 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
</DialogContent>
</Dialog>
{/* Edit Folder Dialog */}
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}>
{/* Rename Folder Dialog */}
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
<DialogContent className="glass-effect bg-card border-border z-50">
<DialogHeader>
<DialogTitle>Editar Pasta</DialogTitle>
<DialogTitle>Renomear Pasta</DialogTitle>
<DialogDescription>
Renomeie sua pasta de conversas.
Digite o novo nome para a pasta "{renamingFolder?.name}".
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-folder-name">Nome da Pasta</Label>
<Label htmlFor="rename-folder-name">Novo Nome</Label>
<Input
id="edit-folder-name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Digite o novo nome..."
onKeyDown={(e) => e.key === "Enter" && handleEditFolder()}
id="rename-folder-name"
value={renamedFolderName}
onChange={(e) => setRenamedFolderName(e.target.value)}
placeholder="Ex: Projetos, Estudos..."
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditFolderOpen(false)}>
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}>
Salvar
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
Renomear
</Button>
</DialogFooter>
</DialogContent>
@@ -420,7 +390,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
<AlertDialogHeader>
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. As conversas dentro da pasta serão movidas para "Sem Pasta".
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -436,6 +406,12 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
{/* Conversations List */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Folders */}
{folders.map((folder) => (
<div key={folder.id} className="space-y-1">
@@ -468,10 +444,10 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem
className="gap-2"
onClick={() => openEditFolder(folder)}
onClick={() => openRenameFolder(folder)}
>
<Edit className="w-3 h-3" />
Editar Nome
Renomear Pasta
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive"
@@ -494,7 +470,6 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
@@ -517,12 +492,26 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && chats.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
<p className="text-sm text-muted-foreground">
Nenhuma conversa ainda
</p>
<p className="text-xs text-muted-foreground mt-1">
Clique em "Novo Chat" para começar
</p>
</div>
)}
</>
)}
</div>
</ScrollArea>
@@ -548,12 +537,11 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
};
interface ChatItemProps {
chat: StoredChat;
chat: ChatRecord;
isSelected: boolean;
onSelect: () => void;
folders: StoredFolder[];
folders: FolderRecord[];
onMoveToFolder: (chatId: string, folderId: string) => void;
onRemoveFromFolder: (chatId: string) => void;
onDelete: () => void;
}
@@ -563,50 +551,29 @@ const ChatItem = ({
onSelect,
folders,
onMoveToFolder,
onRemoveFromFolder,
onDelete,
}: ChatItemProps) => {
// Pega a última mensagem do usuário
const lastUserMessage = chat.messages
.filter(m => m.role === 'user')
.slice(-1)[0];
// Formata timestamp relativo
const timeAgo = formatDistanceToNow(new Date(chat.updatedAt), {
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
addSuffix: true,
locale: ptBR,
});
// Limita o título a 40 caracteres
const truncatedTitle = chat.title.length > 40
? chat.title.substring(0, 40) + '...'
: chat.title;
// Limita a mensagem a 50 caracteres
const truncatedMessage = lastUserMessage?.content.length > 50
? lastUserMessage.content.substring(0, 50) + '...'
: lastUserMessage?.content;
return (
<div
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
isSelected
? "bg-sidebar-accent cyber-border"
: "hover:bg-muted/50"
}`}
onClick={onSelect}
>
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0 max-w-[180px]">
<p className="text-sm font-medium truncate" title={chat.title}>
{truncatedTitle}
{chat.title}
</p>
{lastUserMessage && (
<p className="text-xs text-muted-foreground truncate" title={lastUserMessage.content}>
{truncatedMessage}
</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
{timeAgo}
</p>
</div>
@@ -622,17 +589,8 @@ const ChatItem = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
{chat.folderId && (
<DropdownMenuItem
className="gap-2"
onClick={() => onRemoveFromFolder(chat.id)}
>
<FolderInput className="w-3 h-3" />
Remover da Pasta
</DropdownMenuItem>
)}
{folders.map((folder) => (
{/* Opções para mover para pastas */}
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
<DropdownMenuItem
key={folder.id}
className="gap-2"
+96 -43
View File
@@ -4,10 +4,13 @@ import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { chatService, StoredChat } from "@/services/chat";
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
import { useToast } from "@/hooks/use-toast";
import { getModelId } from "@/config/models";
// Texto padrão da personalidade (usado para novos chats)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface Message {
id: string;
role: "user" | "assistant";
@@ -26,7 +29,8 @@ export const ChatView = () => {
const [selectedModel, setSelectedModel] = useState("GPT-4o");
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
const [currentChatId, setCurrentChatId] = useState("0");
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
// Inicia com texto padrão - será atualizado ao carregar chat existente
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
@@ -38,44 +42,46 @@ export const ChatView = () => {
const [isLoading, setIsLoading] = useState(false);
// Salva o chat no localStorage sempre que as mensagens mudam
useEffect(() => {
if (messages.length > 1) { // Salva apenas se houver mensagens além da inicial
saveCurrentChat();
// Dispara evento customizado para a sidebar recarregar
window.dispatchEvent(new Event('chatUpdated'));
}
}, [messages]);
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
// quando enviadas via handleSendMessage
// useEffect(() => {
// if (messages.length > 1) {
// saveCurrentChat();
// window.dispatchEvent(new Event('chatUpdated'));
// }
// }, [messages]);
// Função para salvar o chat atual
const saveCurrentChat = () => {
try {
const chatTitle = chatService.generateChatTitle(
messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
);
const storedChat: StoredChat = {
id: currentChatId,
title: chatTitle,
createdAt: new Date(),
updatedAt: new Date(),
model: selectedModel,
systemPrompt: systemPrompt,
messages: messages.map(msg => ({
...msg,
timestamp: new Date(),
})),
};
chatService.saveChat(storedChat);
} catch (error) {
console.error('Erro ao salvar chat:', error);
}
};
// NOTA: Função de salvamento localStorage desabilitada - migrado para banco de dados
// const saveCurrentChat = () => {
// try {
// const chatTitle = chatService.generateChatTitle(
// messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
// );
// const storedChat: StoredChat = {
// id: currentChatId,
// title: chatTitle,
// createdAt: new Date(),
// updatedAt: new Date(),
// model: selectedModel,
// systemPrompt: systemPrompt,
// messages: messages.map(msg => ({
// ...msg,
// timestamp: new Date(),
// })),
// };
// chatService.saveChat(storedChat);
// } catch (error) {
// console.error('Erro ao salvar chat:', error);
// }
// };
const handleNewChat = () => {
// Reseta para "0" - novo chat sempre começa com chat_id "0"
setCurrentChatId("0");
// Reseta a personalidade para o texto padrão
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
setMessages([
{
id: "1",
@@ -89,22 +95,58 @@ export const ChatView = () => {
window.dispatchEvent(new Event('chatUpdated'));
};
const handleLoadChat = (chat: StoredChat) => {
// Carrega um chat existente do histórico
const handleLoadChat = async (chat: ChatRecord) => {
// Carrega um chat existente do banco de dados
setCurrentChatId(chat.id);
setSelectedModel(chat.model);
setSystemPrompt(chat.systemPrompt);
// Converte mensagens do StoredChat para Message
const loadedMessages: Message[] = chat.messages.map(msg => ({
// Atualiza a personalidade com o valor do banco de dados
// Se não tiver personalidade salva, usa o texto padrão
setSystemPrompt(chat.personalidade || DEFAULT_SYSTEM_PROMPT);
// Limpa mensagens enquanto carrega
setMessages([]);
setIsLoading(true);
try {
// Busca mensagens do chat na API
const messagesFromAPI = await chatService.getChatMessages(chat.id);
// Converte MessageRecord para Message
const loadedMessages: Message[] = messagesFromAPI.map((msg: MessageRecord) => ({
id: msg.id,
role: msg.role,
content: msg.content,
model: msg.model,
attachments: msg.attachments,
model: msg.model_name, // Nome do modelo retornado pela API
attachments: msg.has_attachments ? [] : undefined, // Não temos detalhes dos anexos no GET
}));
setMessages(loadedMessages);
toast({
title: "Chat carregado",
description: `${loadedMessages.length} mensagens carregadas.`,
});
} catch (error: any) {
console.error('Erro ao carregar mensagens:', error);
toast({
title: "Erro ao carregar chat",
description: error.message || "Não foi possível carregar as mensagens.",
variant: "destructive",
});
// Inicia com mensagem padrão em caso de erro
setMessages([
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: selectedModel,
},
]);
} finally {
setIsLoading(false);
}
};
const handleSendMessage = async (content: string, files?: File[]) => {
@@ -142,7 +184,18 @@ export const ChatView = () => {
// Isso mantém o contexto da conversa para as próximas mensagens
if (response.chat_id && response.chat_id !== currentChatId) {
console.log(`Chat ID atualizado: ${currentChatId}${response.chat_id}`);
// Se estava com chat_id "0", significa que é a primeira mensagem
// e o chat acabou de ser criado no backend
const isFirstMessage = currentChatId === "0";
setCurrentChatId(response.chat_id);
// Dispara evento para ChatSidebar recarregar e mostrar o novo chat
if (isFirstMessage) {
console.log('Primeira mensagem - novo chat criado, atualizando sidebar');
window.dispatchEvent(new Event('chatUpdated'));
}
}
const aiResponse: Message = {
+204 -71
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Download, Trash2, Sparkles, Clock, Search } from "lucide-react";
import { Download, Trash2, Sparkles, Clock, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import {
@@ -14,41 +14,61 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { imageGenerationService, IMAGE_SIZE_OPTIONS, ImageSize } from "@/services/imageGeneration";
interface GeneratedImage {
id: string;
url: string;
prompt: string;
size: ImageSize;
timestamp: Date;
}
import {
imageGenerationService,
IMAGE_SIZE_OPTIONS,
ImageSize,
ImageRecord
} from "@/services/imageGeneration";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
export const ImageView = () => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
const [searchQuery, setSearchQuery] = useState("");
const [images, setImages] = useState<GeneratedImage[]>([]);
const [images, setImages] = useState<ImageRecord[]>([]);
const [lastGeneratedImage, setLastGeneratedImage] = useState<ImageRecord | null>(null);
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega histórico do localStorage ao montar o componente
useEffect(() => {
const savedImages = localStorage.getItem('imageHistory');
if (savedImages) {
// Carrega imagens do banco de dados ao montar o componente
const loadImages = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingImages(true);
try {
const parsedImages = JSON.parse(savedImages);
// Converte strings de data de volta para Date objects
const imagesWithDates = parsedImages.map((img: any) => ({
...img,
timestamp: new Date(img.timestamp),
}));
setImages(imagesWithDates);
} catch (error) {
console.error('Erro ao carregar histórico de imagens:', error);
const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedImages)) {
setImages(fetchedImages);
} else {
console.warn('Resposta da API não é um array:', fetchedImages);
setImages([]);
}
} catch (error: any) {
console.error('Erro ao carregar imagens:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de imagens.",
variant: "destructive",
});
setImages([]);
} finally {
setIsLoadingImages(false);
}
}, []);
};
// Carrega imagens ao montar e quando a paginação mudar
useEffect(() => {
loadImages();
}, [currentPage, perPage]);
const handleGenerate = async () => {
// Valida a descrição antes de enviar
@@ -72,38 +92,54 @@ export const ImageView = () => {
});
// Verifica se a geração foi bem-sucedida
if (response.success) {
// Log da URL da imagem para debug
console.log('URL da imagem gerada:', response.image_url);
const newImage: GeneratedImage = {
id: response.image_generation_id,
url: response.image_url,
prompt: response.message,
size: selectedSize,
timestamp: new Date(),
};
const newHistory = [newImage, ...images].slice(0, 20); // Mantém apenas as últimas 20 imagens
setImages(newHistory);
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
if (response.success && response.image_url && response.image_generation_id) {
toast({
title: "Imagem gerada com sucesso",
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
});
// Cria objeto da imagem recém-gerada para exibição imediata
const newGeneratedImage: ImageRecord = {
id: response.image_generation_id,
user_email: '', // Será preenchido pelo backend
estabelecimento_id: 0, // Será preenchido pelo backend
description: response.message, // Descrição original
model: 'dall-e-3', // Modelo padrão
image_url: response.image_url,
size: selectedSize,
cost_usd: '0',
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
created_at: new Date().toISOString(),
};
// Salva a última imagem gerada para exibição
setLastGeneratedImage(newGeneratedImage);
// Limpa o campo de descrição após sucesso
setPrompt("");
// Recarrega a lista de imagens (sem aguardar para não bloquear a UI)
loadImages(1, perPage);
setCurrentPage(1);
} else {
throw new Error('Erro ao gerar imagem');
// Se success for false ou faltarem dados, trata como erro
throw {
message: response.message || 'Erro ao gerar imagem',
code: response.code,
};
}
} catch (error: any) {
console.error('Erro na geração de imagem:', error);
// Exibe a mensagem de erro exata retornada pela API
const errorMessage = error.message || "Não foi possível gerar a imagem. Tente novamente.";
const errorCode = error.code ? ` (${error.code})` : '';
toast({
title: "Erro na geração",
description: error.message || "Não foi possível gerar a imagem. Tente novamente.",
description: errorMessage + errorCode,
variant: "destructive",
});
} finally {
@@ -111,22 +147,41 @@ export const ImageView = () => {
}
};
const handleDelete = (id: string) => {
const newHistory = images.filter((img) => img.id !== id);
setImages(newHistory);
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
const handleDelete = async (id: string) => {
try {
const result = await imageGenerationService.deleteImage(id);
if (result.success) {
toast({
title: "Imagem removida",
description: "A imagem foi removida do histórico.",
description: "A imagem foi removida com sucesso.",
});
// Se a imagem deletada for a última gerada, limpa o preview
if (lastGeneratedImage && lastGeneratedImage.id === id) {
setLastGeneratedImage(null);
}
// Recarrega a lista de imagens
await loadImages();
} else {
throw new Error(result.message || 'Erro ao deletar imagem');
}
} catch (error: any) {
console.error('Erro ao deletar imagem:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a imagem.",
variant: "destructive",
});
}
};
const handleDownload = async (image: GeneratedImage) => {
const handleDownload = async (image: ImageRecord) => {
try {
await imageGenerationService.downloadImage(
image.url,
`${image.prompt.substring(0, 30)}_${image.size}.png`
image.image_url,
`${image.description.substring(0, 30)}_${image.size}.png`
);
toast({
@@ -142,9 +197,20 @@ export const ImageView = () => {
}
};
const filteredImages = images.filter((img) =>
img.prompt.toLowerCase().includes(searchQuery.toLowerCase())
);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1); // Reset para primeira página ao mudar itens por página
};
const filteredImages = Array.isArray(images)
? images.filter((img) =>
img.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
@@ -227,11 +293,11 @@ export const ImageView = () => {
)}
{/* Recent Images Preview */}
{!isGenerating && images.length > 0 && (
{!isGenerating && lastGeneratedImage && (
<div>
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
<ImageCard
image={images[0]}
image={lastGeneratedImage}
onDelete={handleDelete}
onDownload={handleDownload}
/>
@@ -241,8 +307,9 @@ export const ImageView = () => {
{/* History Tab */}
<TabsContent value="history" className="space-y-4">
{/* Search */}
<div className="relative">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
@@ -251,9 +318,30 @@ export const ImageView = () => {
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingImages ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando imagens...</p>
</div>
) : (
<>
{/* Images Grid */}
{filteredImages.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredImages.map((image) => (
<ImageCard
@@ -264,6 +352,48 @@ export const ImageView = () => {
/>
))}
</div>
{/* Pagination */}
{!searchQuery && images.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={images.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
) : (
<div className="glass-effect rounded-xl p-12 text-center">
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
@@ -272,6 +402,8 @@ export const ImageView = () => {
</p>
</div>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
@@ -282,9 +414,9 @@ export const ImageView = () => {
};
interface ImageCardProps {
image: GeneratedImage;
image: ImageRecord;
onDelete: (id: string) => void;
onDownload: (image: GeneratedImage) => void;
onDownload: (image: ImageRecord) => void;
}
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
@@ -292,9 +424,10 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
const [imageLoading, setImageLoading] = useState(true);
// Calcula tempo relativo
const getRelativeTime = (timestamp: Date) => {
const getRelativeTime = (timestamp: string) => {
const now = new Date();
const diff = now.getTime() - new Date(timestamp).getTime();
const date = new Date(timestamp);
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
@@ -306,13 +439,13 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
};
const handleImageError = () => {
console.warn('Erro CORS ao carregar imagem:', image.url);
console.warn('Erro CORS ao carregar imagem:', image.image_url);
setImageError(true);
setImageLoading(false);
};
const handleImageLoad = () => {
console.log('Imagem carregada com sucesso:', image.url);
console.log('Imagem carregada com sucesso:', image.image_url);
setImageLoading(false);
setImageError(false);
};
@@ -332,7 +465,7 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
<p className="text-xs text-center mb-2">A imagem foi gerada, mas não pode ser exibida aqui</p>
<div className="flex flex-col gap-2 w-full">
<a
href={image.url}
href={image.image_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
@@ -340,7 +473,7 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
Abrir imagem em nova aba
</a>
<button
onClick={() => navigator.clipboard.writeText(image.url)}
onClick={() => navigator.clipboard.writeText(image.image_url)}
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
>
Copiar URL
@@ -349,8 +482,8 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
</div>
) : (
<img
src={image.url}
alt={image.prompt}
src={image.image_url}
alt={image.description}
className="w-full h-full object-cover"
onError={handleImageError}
onLoad={handleImageLoad}
@@ -360,10 +493,10 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
<p className="text-sm text-white line-clamp-2">{image.description}</p>
<div className="flex items-center justify-between text-xs text-white/70">
<span>{IMAGE_SIZE_OPTIONS[image.size].label}</span>
<span>{getRelativeTime(image.timestamp)}</span>
<span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
<span>{getRelativeTime(image.created_at)}</span>
</div>
<div className="flex gap-2">
<Button
+42 -16
View File
@@ -9,6 +9,21 @@ export interface ModelConfig {
description?: string; // Descrição opcional do modelo
}
/**
* Interface para modelo retornado pela API do banco de dados
*/
export interface ModelIA {
id: number;
provider_id: number;
name: string;
model_identifier: string;
cost_input_per_million: string;
cost_output_per_million: string;
is_active: number;
created_at: string;
updated_at: string;
}
/**
* Lista de modelos disponíveis
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
@@ -112,21 +127,32 @@ export function getModelNames(): string[] {
}
/**
* FUTURA INTEGRAÇÃO COM BANCO DE DADOS
* Busca modelos de IA disponíveis no banco de dados
*
* Quando a API estiver pronta, substituir AVAILABLE_MODELS por chamada à API:
*
* export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
* const response = await apiService.get('/api/models');
* return response.data.map((model: any) => ({
* name: model.name,
* id: model.id.toString(),
* description: model.description || '',
* }));
* }
*
* Nos componentes, usar:
* - useEffect para carregar modelos na montagem
* - useState para armazenar lista de modelos
* - Loading state durante o fetch
* @returns Promise com array de modelos ativos
*/
export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
try {
// Importa dinamicamente para evitar circular dependency
const { apiService } = await import('@/services/api');
const response = await apiService.get<ModelIA[]>('/webhook/codex/get_models_ia');
console.log('Modelos carregados da API:', response.data);
// Filtra apenas modelos ativos e converte para ModelConfig
return response.data
.filter(model => model.is_active === 1)
.map((model: ModelIA) => ({
name: model.name,
id: model.id.toString(),
description: `${model.model_identifier}`,
}));
} catch (error) {
console.error('Erro ao buscar modelos da API:', error);
// Fallback para modelos hardcoded em caso de erro
console.warn('Usando modelos hardcoded como fallback');
return AVAILABLE_MODELS;
}
}
+374
View File
@@ -0,0 +1,374 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Interface para resposta do POST de parecer técnico
*/
export interface CreateOpinionResponse {
success: boolean;
id?: string;
file_url?: string;
file_url_melhoria?: string;
}
/**
* Interface para requisição de criação de parecer
*/
export interface CreateOpinionRequest {
titulo: string;
categoria: string;
instrucoes: string;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Interface para um parecer retornado pela API
*/
export interface OpinionRecord {
id: string;
estabelecimento_id: number;
user_email: string;
titulo: string;
categoria: string;
instrucoes: string;
file_url: string;
created_at: string;
file_url_melhoria: string;
}
/**
* Interface para parâmetros de paginação e busca
*/
export interface GetOpinionsParams {
page?: number;
per_page?: number;
search?: string;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Interface para resposta do GET de pareceres
*/
export interface GetOpinionsResponse {
data: OpinionRecord[];
total: number;
page: number;
per_page: number;
}
/**
* Serviço para gerenciamento de pareceres jurídicos
*/
class AgentService {
private readonly CREATE_OPINION_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
private readonly GET_OPINIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_parecer';
/**
* Cria um novo parecer técnico
*
* @param request - Dados do parecer
* @returns Promise com a resposta da API
*/
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
const {
titulo,
categoria,
instrucoes,
userEmail,
estabelecimentoId
} = request;
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
// Validações
if (!titulo || titulo.trim().length === 0) {
throw {
success: false,
message: 'Título do parecer é obrigatório',
};
}
if (!instrucoes || instrucoes.trim().length === 0) {
throw {
success: false,
message: 'Instruções são obrigatórias',
};
}
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!estabId) {
throw {
success: false,
message: 'ID do estabelecimento não fornecido',
};
}
// Log para debug
console.log('Criando parecer:', {
titulo,
categoria,
instrucoesLength: instrucoes.length,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.post<CreateOpinionResponse>(
this.CREATE_OPINION_ENDPOINT,
{
user_email: email,
estabelecimento_id: estabId,
titulo: titulo.trim(),
categoria: categoria.trim(),
instrucoes: instrucoes.trim(),
}
);
console.log('Resposta da API (criar parecer):', response.data);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro ao criar parecer:', error);
throw {
success: false,
message: error.message || 'Erro ao criar parecer',
status: error.status,
};
}
}
/**
* Busca todos os pareceres do usuário com paginação e busca
*
* @param params - Parâmetros de paginação e busca
* @returns Promise com array de pareceres
*/
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
const {
page = 1,
per_page = 10,
search = '',
userEmail,
estabelecimentoId
} = params || {};
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!estabId) {
throw {
success: false,
message: 'ID do estabelecimento não fornecido',
};
}
console.log('Buscando pareceres:', {
userEmail: email,
estabelecimentoId: estabId,
page,
per_page,
search,
});
try {
// Constrói a URL com parâmetros de query
const url = `${this.GET_OPINIONS_ENDPOINT}/${email}/${estabId}`;
const response = await apiService.get<OpinionRecord[]>(url, {
params: {
page,
per_page,
search,
},
});
console.log('Resposta da API (buscar pareceres):', response.data);
// A API retorna diretamente o array de pareceres
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto, tenta encontrar o array dentro dele
console.warn('API retornou objeto em vez de array:', response.data);
if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
} else if (Array.isArray((response.data as any).opinions)) {
return (response.data as any).opinions;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de pareceres da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar pareceres:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar pareceres',
status: error.status,
};
}
}
/**
* Faz download de um arquivo de parecer
*
* @param fileUrl - URL do arquivo a ser baixado
* @param fileName - Nome do arquivo para download
*/
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
if (!fileUrl) {
throw {
success: false,
message: 'URL do arquivo não fornecida',
};
}
try {
console.log('Iniciando download:', { fileUrl, fileName });
// Tenta primeiro fazer o download via fetch (funciona se CORS estiver configurado)
try {
const response = await fetch(fileUrl, {
method: 'GET',
mode: 'cors',
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`Erro HTTP: ${response.status}`);
}
// Converte a resposta em blob
const blob = await response.blob();
// Cria uma URL temporária para o blob
const blobUrl = URL.createObjectURL(blob);
// Cria um elemento <a> temporário para forçar o download
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
// Remove o elemento e libera a URL temporária
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
console.log('Download via fetch concluído:', { fileUrl, fileName });
return;
} catch (fetchError: any) {
console.warn('Erro no download via fetch, tentando método alternativo:', fetchError.message);
// Se falhar (erro de CORS), usa o método alternativo de abrir em nova aba
// Isso permite que o navegador force o download mesmo com restrições de CORS
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Para S3, podemos tentar adicionar parâmetros que forçam o download
const url = new URL(fileUrl);
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
link.href = url.toString();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('Download via link direto iniciado:', { fileUrl, fileName });
}
} catch (error: any) {
console.error('Erro ao fazer download:', error);
throw {
success: false,
message: error.message || 'Erro ao fazer download do arquivo. Verifique se a URL está acessível.',
};
}
}
/**
* Exclui um parecer técnico
*
* @param opinionId - ID do parecer a ser excluído
* @param userEmail - Email do usuário (opcional, usa GlobalFunctions se não fornecido)
* @returns Promise com a resposta da API
*/
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!opinionId) {
throw {
success: false,
message: 'ID do parecer não fornecido',
};
}
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Excluindo parecer:', {
opinionId,
userEmail: email,
});
try {
// Constrói a URL com o user_email e id
const url = `/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_parecer/${email}/${opinionId}`;
const response = await apiService.delete<{ success: boolean }[]>(url);
console.log('Resposta da API (excluir parecer):', response.data);
// A API retorna um array com { success: true }
if (Array.isArray(response.data) && response.data.length > 0) {
return response.data[0];
}
return { success: true };
} catch (error: any) {
console.error('Erro ao excluir parecer:', error);
throw {
success: false,
message: error.message || 'Erro ao excluir parecer',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const agentService = new AgentService();
+162
View File
@@ -16,6 +16,23 @@ export interface AudioGenerationResponse {
message: string; // Texto que foi convertido em áudio
}
/**
* Interface para um áudio armazenado no banco de dados
*/
export interface AudioRecord {
id: string;
user_email: string;
estabelecimento_id: number;
input_text: string;
model: string;
voice: VoiceType;
audio_url: string;
duration_seconds: number | null;
file_size: number;
cost_usd: string;
created_at: string;
}
/**
* Interface para os dados necessários para geração de áudio
*/
@@ -73,6 +90,8 @@ export const VOICE_OPTIONS = {
*/
class AudioGenerationService {
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios';
private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
/**
* Gera um arquivo de áudio a partir de texto
@@ -192,6 +211,149 @@ class AudioGenerationService {
return { valid: true };
}
/**
* Lista áudios do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de áudios
*/
async getAudios(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<AudioRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando áudios:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<AudioRecord[]>(
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de áudios
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'audios' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).audios)) {
return (response.data as any).audios;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de áudios da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar áudios:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar áudios',
status: error.status,
};
}
}
/**
* Deleta um áudio do banco de dados
*
* @param audioId - ID do áudio a ser deletado
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!audioId) {
throw {
success: false,
message: 'ID do áudio não fornecido',
};
}
console.log('Deletando áudio:', {
audioId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Áudio deletado com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar áudio:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar áudio',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
+510 -4
View File
@@ -59,6 +59,56 @@ export interface StoredFolder {
chatIds: string[];
}
/**
* Interface para Folder no banco de dados
*/
export interface FolderRecord {
id: string;
name: string;
created_at: string;
updated_at: string;
}
/**
* Interface para Chat no banco de dados
*/
export interface ChatRecord {
id: string;
title: string;
model_id: number;
folder_id: string | null;
created_at: string;
updated_at: string;
personalidade: string; // Personalidade/prompt do sistema salvo no banco
estabelecimento_id: number;
}
/**
* Interface para resposta do GET de chats e folders
*/
export interface GetChatsAndFoldersResponse {
chats: ChatRecord[];
folders: FolderRecord[];
}
/**
* Interface para Message no banco de dados
*/
export interface MessageRecord {
id: string;
chat_id: string;
role: 'user' | 'assistant';
content: string;
model_id: number;
model_name?: string; // Nome do modelo retornado pela API
has_attachments: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost_usd: string;
created_at: string;
}
/**
* Serviço de chat com IA
*/
@@ -67,6 +117,14 @@ class ChatService {
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
// Endpoints para folders e chats
private readonly POST_FOLDER_ENDPOINT = '/webhook/codex/post_folders';
private readonly GET_CHATS_FOLDERS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_folders';
private readonly PUT_CHAT_IN_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/insert_chat_in_folder';
private readonly DELETE_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_folder';
private readonly DELETE_CHAT_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_messages';
private readonly GET_CHAT_MESSAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_messages';
// Formatos de arquivo permitidos (atualmente)
private readonly ALLOWED_FILE_TYPES = {
// Formatos ativos
@@ -280,11 +338,11 @@ class ChatService {
}
/**
* Deleta um chat
* Deleta um chat do localStorage
*
* @param chatId - ID do chat a ser deletado
*/
deleteChat(chatId: string): void {
deleteChatLocal(chatId: string): void {
try {
const chats = this.getAllChats();
const filteredChats = chats.filter(c => c.id !== chatId);
@@ -342,11 +400,11 @@ class ChatService {
}
/**
* Deleta uma pasta
* Deleta uma pasta do localStorage
*
* @param folderId - ID da pasta a ser deletada
*/
deleteFolder(folderId: string): void {
deleteFolderLocal(folderId: string): void {
try {
const folders = this.getAllFolders();
const filteredFolders = folders.filter(f => f.id !== folderId);
@@ -476,6 +534,454 @@ class ChatService {
getMaxAttachments(): number {
return this.MAX_ATTACHMENTS;
}
// ===== MÉTODOS DE INTEGRAÇÃO COM BANCO DE DADOS =====
/**
* Cria uma nova pasta no banco de dados
*
* @param name - Nome da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async createFolder(name: string, userEmail?: string): Promise<{ success: boolean; folder?: FolderRecord }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!name || name.trim().length === 0) {
throw {
success: false,
message: 'Nome da pasta não pode estar vazio',
};
}
console.log('Criando pasta:', {
name,
userEmail: email,
});
try {
const response = await apiService.post<Array<{ success: boolean; user_email?: string; name?: string }>>(
this.POST_FOLDER_ENDPOINT,
{
user_email: email,
name: name.trim(),
}
);
console.log('Resposta completa do POST:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true, ...}]
let result: { success: boolean; user_email?: string; name?: string };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data as any;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao criar pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao criar pasta',
status: error.status,
};
}
}
/**
* Renomeia uma pasta existente
*
* @param folderId - ID da pasta a ser renomeada
* @param newName - Novo nome da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com o resultado da operação
*/
async renameFolder(folderId: string, newName: string, userEmail?: string): Promise<{ success: boolean; folder?: FolderRecord }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!folderId || folderId.trim().length === 0) {
throw {
success: false,
message: 'ID da pasta não pode estar vazio',
};
}
if (!newName || newName.trim().length === 0) {
throw {
success: false,
message: 'Nome da pasta não pode estar vazio',
};
}
console.log('Renomeando pasta:', {
folderId,
newName,
userEmail: email,
});
try {
const response = await apiService.put<Array<{ success: boolean; user_email?: string; name?: string; id?: string }>>(
this.POST_FOLDER_ENDPOINT,
{
id: folderId,
user_email: email,
name: newName.trim(),
}
);
console.log('Resposta completa do PUT (renomear pasta):', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true, "user_email": "...", "name": "...", "id": "..."}]
let result: { success: boolean; user_email?: string; name?: string; id?: string };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data as any;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao renomear pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao renomear pasta',
status: error.status,
};
}
}
/**
* Busca todos os chats e pastas do usuário
*
* @param userEmail - Email do usuário (opcional)
* @returns Promise com chats e folders
*/
async getChatsAndFolders(userEmail?: string): Promise<GetChatsAndFoldersResponse> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando chats e pastas:', {
userEmail: email,
});
try {
const response = await apiService.get<Array<{ result: GetChatsAndFoldersResponse }>>(
`${this.GET_CHATS_FOLDERS_ENDPOINT}/${email}`
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto "result": [{"result": {"chats": [...], "folders": [...]}}]
let result: GetChatsAndFoldersResponse;
if (Array.isArray(response.data) && response.data.length > 0) {
result = response.data[0].result;
console.log('API retornou array com result:', result);
} else if ((response.data as any).result) {
result = (response.data as any).result;
console.log('API retornou objeto com result:', result);
} else {
// Fallback: retorna vazio
console.warn('Estrutura inesperada da resposta');
result = { chats: [], folders: [] };
}
// Garante que chats e folders são arrays
return {
chats: Array.isArray(result.chats) ? result.chats : [],
folders: Array.isArray(result.folders) ? result.folders : [],
};
} catch (error: any) {
console.error('Erro ao buscar chats e pastas:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar chats e pastas',
status: error.status,
};
}
}
/**
* Move um chat para dentro de uma pasta
*
* @param chatId - ID do chat
* @param folderId - ID da pasta
* @returns Promise com sucesso ou erro
*/
async moveChatToFolder(chatId: string, folderId: string): Promise<{ success: boolean }> {
if (!chatId || !folderId) {
throw {
success: false,
message: 'Chat ID e Folder ID são obrigatórios',
};
}
console.log('Movendo chat para pasta:', {
chatId,
folderId,
});
try {
const response = await apiService.put<Array<{ success: boolean }>>(
`${this.PUT_CHAT_IN_FOLDER_ENDPOINT}/${folderId}/${chatId}`
);
console.log('Resposta completa do PUT:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao mover chat para pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao mover chat para pasta',
status: error.status,
};
}
}
/**
* Deleta uma pasta do banco de dados
*
* @param folderId - ID da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteFolder(folderId: string, userEmail?: string): Promise<{ success: boolean }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!folderId) {
throw {
success: false,
message: 'ID da pasta não fornecido',
};
}
console.log('Deletando pasta:', {
folderId,
userEmail: email,
});
try {
const response = await apiService.delete<Array<{ success: boolean }>>(
`${this.DELETE_FOLDER_ENDPOINT}/${email}/${folderId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao deletar pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar pasta',
status: error.status,
};
}
}
/**
* Deleta um chat do banco de dados
*
* @param chatId - ID do chat
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteChat(chatId: string, userEmail?: string): Promise<{ success: boolean }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!chatId) {
throw {
success: false,
message: 'ID do chat não fornecido',
};
}
console.log('Deletando chat:', {
chatId,
userEmail: email,
});
try {
const response = await apiService.delete<Array<{ success: boolean }>>(
`${this.DELETE_CHAT_ENDPOINT}/${email}/${chatId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao deletar chat:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar chat',
status: error.status,
};
}
}
/**
* Busca todas as mensagens de um chat específico
*
* @param chatId - ID do chat
* @param userEmail - Email do usuário (opcional)
* @returns Promise com array de mensagens
*/
async getChatMessages(chatId: string, userEmail?: string): Promise<MessageRecord[]> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!chatId) {
throw {
success: false,
message: 'ID do chat não fornecido',
};
}
console.log('Buscando mensagens do chat:', {
chatId,
userEmail: email,
});
try {
const response = await apiService.get<MessageRecord[]>(
`${this.GET_CHAT_MESSAGES_ENDPOINT}/${email}/${chatId}`
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de mensagens
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'messages' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).messages)) {
return (response.data as any).messages;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de mensagens da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar mensagens:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar mensagens',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
+210 -9
View File
@@ -11,9 +11,39 @@ export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
*/
export interface ImageGenerationResponse {
success: boolean;
image_url: string; // URL da imagem gerada
image_generation_id: string;
message: string; // Descrição original
image_url?: string; // URL da imagem gerada (opcional quando há erro)
image_generation_id?: string; // ID da geração (opcional quando há erro)
message: string; // Descrição original ou mensagem de erro
code?: string; // Código de erro (ex: "server_error", "invalid_request")
}
/**
* Interface para uma imagem armazenada no banco de dados
*/
export interface ImageRecord {
id: string;
user_email: string;
estabelecimento_id: number;
description: string;
model: string;
image_url: string;
size: ImageSize;
cost_usd: string;
total_tokens: number;
input_tokens: number;
output_tokens: number;
created_at: string;
}
/**
* Interface para resposta paginada de imagens
*/
export interface GetImagesResponse {
images: ImageRecord[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
/**
@@ -55,6 +85,8 @@ export const IMAGE_SIZE_OPTIONS = {
*/
class ImageGenerationService {
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
private readonly GET_IMAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_images';
private readonly DELETE_IMAGE_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_images';
/**
* Gera uma imagem a partir de uma descrição em texto
@@ -106,14 +138,39 @@ class ImageGenerationService {
}
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na geração de imagem:', error);
// Verifica se a resposta indica erro
if (!response.data.success) {
throw {
success: false,
message: error.message || 'Erro ao gerar imagem',
message: response.data.message || 'Erro ao gerar imagem',
code: response.data.code,
};
}
return response.data;
} catch (error: any) {
// Trata erros específicos da API
console.error('Erro na geração de imagem:', error);
// Se o erro já tem a estrutura esperada (veio da validação acima), repassa
if (error.success === false && error.message) {
throw error;
}
// Se o erro veio da requisição HTTP, tenta extrair a resposta da API
if (error.response?.data) {
const apiError = error.response.data;
throw {
success: false,
message: apiError.message || 'Erro ao gerar imagem',
code: apiError.code,
};
}
// Erro genérico
throw {
success: false,
message: error.message || 'Erro ao gerar imagem. Tente novamente.',
status: error.status,
};
}
@@ -213,6 +270,150 @@ class ImageGenerationService {
throw new Error('Não foi possível baixar a imagem');
}
}
/**
* Lista imagens do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com a resposta paginada
*/
async getImages(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<ImageRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando imagens:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<ImageRecord[]>(
`${this.GET_IMAGES_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de imagens
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'images' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).images)) {
return (response.data as any).images;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de imagens da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar imagens:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar imagens',
status: error.status,
};
}
}
/**
* Deleta uma imagem do banco de dados
*
* @param imageId - ID da imagem a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteImage(imageId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!imageId) {
throw {
success: false,
message: 'ID da imagem não fornecido',
};
}
console.log('Deletando imagem:', {
imageId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API pode retornar um array com um objeto: [{"success":true}]
// ou diretamente um objeto: {"success":true}
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Imagem deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar imagem:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar imagem',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
+161
View File
@@ -11,6 +11,22 @@ export interface TranscriptionResponse {
message: string;
}
/**
* Interface para uma transcrição armazenada no banco de dados
*/
export interface TranscriptionRecord {
id: string;
user_email: string;
estabelecimento_id: number;
audio_file_name: string;
audio_duration_seconds: number;
transcription_text: string;
model: string;
audio_url: string;
cost_usd: string;
created_at: string;
}
/**
* Interface para os dados necessários para transcrição
*/
@@ -25,6 +41,8 @@ export interface TranscriptionRequest {
*/
class TranscriptionService {
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
private readonly GET_TRANSCRIPTIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_transcrever_audio';
private readonly DELETE_TRANSCRIPTION_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_transcrever_audio';
/**
* Transcreve um arquivo de áudio
@@ -105,6 +123,149 @@ class TranscriptionService {
return { valid: true };
}
/**
* Lista transcrições do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de transcrições
*/
async getTranscriptions(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<TranscriptionRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando transcrições:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<TranscriptionRecord[]>(
`${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de transcrições
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'transcriptions' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).transcriptions)) {
return (response.data as any).transcriptions;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de transcrições da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar transcrições:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar transcrições',
status: error.status,
};
}
}
/**
* Deleta uma transcrição do banco de dados
*
* @param transcriptionId - ID da transcrição a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteTranscription(transcriptionId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!transcriptionId) {
throw {
success: false,
message: 'ID da transcrição não fornecido',
};
}
console.log('Deletando transcrição:', {
transcriptionId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Transcrição deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar transcrição',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)