Integração com o banco de dados
This commit is contained in:
@@ -3,7 +3,7 @@ import { ChatHeader } from "@/components/ChatHeader";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -13,33 +13,56 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { audioGenerationService, VOICE_OPTIONS, VoiceType } from "@/services/audioGeneration";
|
import { audioGenerationService, VOICE_OPTIONS, VoiceType, AudioRecord } from "@/services/audioGeneration";
|
||||||
|
|
||||||
interface GeneratedAudio {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
voice: string;
|
|
||||||
voiceLabel: string;
|
|
||||||
audioUrl: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GenerationView = () => {
|
export const GenerationView = () => {
|
||||||
const [textToSpeech, setTextToSpeech] = useState("");
|
const [textToSpeech, setTextToSpeech] = useState("");
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
|
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
|
||||||
const [generatedAudio, setGeneratedAudio] = useState<GeneratedAudio | null>(null);
|
const [lastGeneratedAudio, setLastGeneratedAudio] = useState<AudioRecord | null>(null);
|
||||||
const [audioHistory, setAudioHistory] = useState<GeneratedAudio[]>([]);
|
const [audioHistory, setAudioHistory] = useState<AudioRecord[]>([]);
|
||||||
|
const [isLoadingAudios, setIsLoadingAudios] = useState(false);
|
||||||
const [audioSearchQuery, setAudioSearchQuery] = useState("");
|
const [audioSearchQuery, setAudioSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(10);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
// Carrega áudios do banco de dados
|
||||||
const savedAudios = localStorage.getItem('audioHistory');
|
const loadAudios = async (page: number = currentPage, limit: number = perPage) => {
|
||||||
if (savedAudios) {
|
setIsLoadingAudios(true);
|
||||||
setAudioHistory(JSON.parse(savedAudios));
|
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 () => {
|
const handleGenerateAudio = async () => {
|
||||||
// Valida o texto antes de enviar
|
// Valida o texto antes de enviar
|
||||||
@@ -66,20 +89,23 @@ export const GenerationView = () => {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
console.log('URL do áudio gerado:', response.audio_url);
|
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,
|
id: response.audio_generation_id,
|
||||||
text: response.message,
|
user_email: '',
|
||||||
|
estabelecimento_id: 0,
|
||||||
|
input_text: response.message,
|
||||||
|
model: 'tts-1',
|
||||||
voice: selectedVoice,
|
voice: selectedVoice,
|
||||||
voiceLabel: VOICE_OPTIONS[selectedVoice].label,
|
audio_url: response.audio_url,
|
||||||
audioUrl: response.audio_url,
|
duration_seconds: null,
|
||||||
timestamp: new Date(),
|
file_size: 0,
|
||||||
|
cost_usd: '0',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setGeneratedAudio(audio);
|
// Salva o último áudio gerado para exibição
|
||||||
|
setLastGeneratedAudio(newGeneratedAudio);
|
||||||
const newHistory = [audio, ...audioHistory].slice(0, 10);
|
|
||||||
setAudioHistory(newHistory);
|
|
||||||
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Áudio gerado com sucesso",
|
title: "Áudio gerado com sucesso",
|
||||||
@@ -88,6 +114,10 @@ export const GenerationView = () => {
|
|||||||
|
|
||||||
// Limpa o campo de texto após sucesso
|
// Limpa o campo de texto após sucesso
|
||||||
setTextToSpeech("");
|
setTextToSpeech("");
|
||||||
|
|
||||||
|
// Recarrega a lista de áudios (sem aguardar para não bloquear a UI)
|
||||||
|
loadAudios(1, perPage);
|
||||||
|
setCurrentPage(1);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Erro ao gerar áudio');
|
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');
|
const a = document.createElement('a');
|
||||||
a.href = audio.audioUrl;
|
a.href = audio.audio_url;
|
||||||
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
|
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@@ -118,24 +148,51 @@ export const GenerationView = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAudio = (audioId: string) => {
|
const handleDeleteAudio = async (audioId: string) => {
|
||||||
if (generatedAudio?.id === audioId) {
|
try {
|
||||||
setGeneratedAudio(null);
|
const result = await audioGenerationService.deleteAudio(audioId);
|
||||||
}
|
|
||||||
const newHistory = audioHistory.filter(a => a.id !== audioId);
|
|
||||||
setAudioHistory(newHistory);
|
|
||||||
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
|
|
||||||
|
|
||||||
toast({
|
if (result.success) {
|
||||||
title: "Áudio removido",
|
toast({
|
||||||
description: "O áudio foi removido do histórico.",
|
title: "Áudio removido",
|
||||||
});
|
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 =>
|
const handlePageChange = (page: number) => {
|
||||||
item.voiceLabel.toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
|
setCurrentPage(page);
|
||||||
item.text.toLowerCase().includes(audioSearchQuery.toLowerCase())
|
};
|
||||||
);
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
@@ -222,13 +279,13 @@ export const GenerationView = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{generatedAudio && (
|
{lastGeneratedAudio && (
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold">Áudio Gerado</h4>
|
<h4 className="font-semibold">Áudio Gerado</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Voz: {generatedAudio.voiceLabel}
|
Voz: {VOICE_OPTIONS[lastGeneratedAudio.voice]?.label || lastGeneratedAudio.voice}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -236,7 +293,7 @@ export const GenerationView = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={() => handleDownloadAudio(generatedAudio)}
|
onClick={() => handleDownloadAudio(lastGeneratedAudio)}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
Baixar
|
Baixar
|
||||||
@@ -245,7 +302,7 @@ export const GenerationView = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={() => handleDeleteAudio(generatedAudio.id)}
|
onClick={() => handleDeleteAudio(lastGeneratedAudio.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Excluir
|
Excluir
|
||||||
@@ -254,14 +311,14 @@ export const GenerationView = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
<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
|
<audio
|
||||||
key={generatedAudio.id}
|
key={lastGeneratedAudio.id}
|
||||||
controls
|
controls
|
||||||
className="w-full"
|
className="w-full"
|
||||||
preload="metadata"
|
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.
|
Seu navegador não suporta o elemento de áudio.
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,16 +326,10 @@ export const GenerationView = () => {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="history" className="space-y-6">
|
<TabsContent value="history" className="space-y-4">
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
{/* Search and Filters */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<h3 className="text-lg font-semibold">Histórico de Áudios</h3>
|
<div className="relative flex-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{audioHistory.length} {audioHistory.length === 1 ? 'item' : 'itens'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={audioSearchQuery}
|
value={audioSearchQuery}
|
||||||
@@ -287,59 +338,127 @@ export const GenerationView = () => {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
|
||||||
{filteredAudios.length === 0 ? (
|
<SelectTrigger className="w-full md:w-[180px]">
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<SelectValue />
|
||||||
<Mic className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
</SelectTrigger>
|
||||||
<p>{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio no histórico'}</p>
|
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||||
</div>
|
<SelectItem value="5">5 por página</SelectItem>
|
||||||
) : (
|
<SelectItem value="10">10 por página</SelectItem>
|
||||||
<div className="space-y-3">
|
<SelectItem value="20">20 por página</SelectItem>
|
||||||
{filteredAudios.map((audio) => (
|
<SelectItem value="50">50 por página</SelectItem>
|
||||||
<div key={audio.id} className="bg-muted/30 rounded-lg p-4 space-y-3">
|
</SelectContent>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</Select>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-medium">Voz: {audio.voiceLabel}</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{new Date(audio.timestamp).toLocaleDateString('pt-BR')} às{' '}
|
|
||||||
{new Date(audio.timestamp).toLocaleTimeString('pt-BR')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDownloadAudio(audio)}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDeleteAudio(audio.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{audio.text}
|
|
||||||
</p>
|
|
||||||
<audio
|
|
||||||
key={audio.id}
|
|
||||||
controls
|
|
||||||
className="w-full"
|
|
||||||
preload="metadata"
|
|
||||||
>
|
|
||||||
<source src={audio.audioUrl} type="audio/mpeg" />
|
|
||||||
</audio>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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="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: {VOICE_OPTIONS[audio.voice]?.label || audio.voice}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(audio.created_at).toLocaleDateString('pt-BR')} às{' '}
|
||||||
|
{new Date(audio.created_at).toLocaleTimeString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDownloadAudio(audio)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDeleteAudio(audio.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{audio.input_text}
|
||||||
|
</p>
|
||||||
|
<audio
|
||||||
|
key={audio.id}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,37 +2,67 @@ import { useState, useEffect } from "react";
|
|||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { 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 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 = () => {
|
export const TranscriptionView = () => {
|
||||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
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 [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 [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(10);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
// Carrega transcrições do banco de dados
|
||||||
const savedTranscriptions = localStorage.getItem('transcriptionHistory');
|
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
|
||||||
if (savedTranscriptions) {
|
setIsLoadingTranscriptions(true);
|
||||||
setTranscriptionHistory(JSON.parse(savedTranscriptions));
|
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 handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -64,24 +94,31 @@ export const TranscriptionView = () => {
|
|||||||
|
|
||||||
// Verifica se a transcrição foi bem-sucedida
|
// Verifica se a transcrição foi bem-sucedida
|
||||||
if (response.success) {
|
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,
|
id: response.transcription_id,
|
||||||
fileName: file.name,
|
user_email: '',
|
||||||
text: response.message,
|
estabelecimento_id: 0,
|
||||||
timestamp: new Date(),
|
audio_file_name: file.name,
|
||||||
audioUrl: response.audio_url,
|
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);
|
// Salva a última transcrição para exibição
|
||||||
|
setLastTranscriptionResult(newTranscription);
|
||||||
const newHistory = [result, ...transcriptionHistory].slice(0, 10);
|
|
||||||
setTranscriptionHistory(newHistory);
|
|
||||||
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Transcrição concluída",
|
title: "Transcrição concluída",
|
||||||
description: "Seu áudio foi transcrito com sucesso!",
|
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 {
|
} else {
|
||||||
throw new Error(response.message || 'Erro ao transcrever áudio');
|
throw new Error(response.message || 'Erro ao transcrever áudio');
|
||||||
}
|
}
|
||||||
@@ -99,52 +136,80 @@ export const TranscriptionView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTranscription = () => {
|
const handleDeleteTranscription = () => {
|
||||||
setTranscriptionResult(null);
|
setLastTranscriptionResult(null);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTranscriptionFromHistory = (transcriptionId: string) => {
|
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
|
||||||
if (transcriptionResult?.id === transcriptionId) {
|
try {
|
||||||
setTranscriptionResult(null);
|
const result = await transcriptionService.deleteTranscription(transcriptionId);
|
||||||
}
|
|
||||||
const newHistory = transcriptionHistory.filter(t => t.id !== transcriptionId);
|
|
||||||
setTranscriptionHistory(newHistory);
|
|
||||||
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
|
|
||||||
|
|
||||||
toast({
|
if (result.success) {
|
||||||
title: "Transcrição removida",
|
toast({
|
||||||
description: "A transcrição foi removida do histórico.",
|
title: "Transcrição removida",
|
||||||
});
|
description: "A transcrição foi removida com sucesso.",
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleCopyTranscription = () => {
|
// Se a transcrição deletada for a última gerada, limpa o preview
|
||||||
if (transcriptionResult) {
|
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
|
||||||
navigator.clipboard.writeText(transcriptionResult.text);
|
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({
|
toast({
|
||||||
title: "Texto copiado",
|
title: "Erro ao remover",
|
||||||
description: "A transcrição foi copiada para a área de transferência",
|
description: error.message || "Não foi possível remover a transcrição.",
|
||||||
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadTranscription = () => {
|
const handleCopyTranscription = (text: string) => {
|
||||||
if (transcriptionResult) {
|
navigator.clipboard.writeText(text);
|
||||||
const blob = new Blob([transcriptionResult.text], { type: 'text/plain' });
|
toast({
|
||||||
const url = URL.createObjectURL(blob);
|
title: "Texto copiado",
|
||||||
const a = document.createElement('a');
|
description: "A transcrição foi copiada para a área de transferência",
|
||||||
a.href = url;
|
});
|
||||||
a.download = `transcricao_${transcriptionResult.fileName}.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTranscriptions = transcriptionHistory.filter(item =>
|
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
|
||||||
item.fileName.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
|
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
|
||||||
item.text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
|
const url = URL.createObjectURL(blob);
|
||||||
);
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
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 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 (
|
return (
|
||||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
@@ -222,13 +287,13 @@ export const TranscriptionView = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transcriptionResult && (
|
{lastTranscriptionResult && (
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-3">
|
<div className="glass-effect rounded-xl p-6 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold">{transcriptionResult.fileName}</h4>
|
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -236,7 +301,7 @@ export const TranscriptionView = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={handleCopyTranscription}
|
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
Copiar
|
Copiar
|
||||||
@@ -245,7 +310,7 @@ export const TranscriptionView = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={handleDownloadTranscription}
|
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
Baixar
|
Baixar
|
||||||
@@ -263,23 +328,17 @@ export const TranscriptionView = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{transcriptionResult.text}
|
{lastTranscriptionResult.transcription_text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="history" className="space-y-6">
|
<TabsContent value="history" className="space-y-4">
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
{/* Search and Filters */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<h3 className="text-lg font-semibold">Histórico de Transcrições</h3>
|
<div className="relative flex-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{transcriptionHistory.length} {transcriptionHistory.length === 1 ? 'item' : 'itens'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={transcriptionSearchQuery}
|
value={transcriptionSearchQuery}
|
||||||
@@ -288,40 +347,122 @@ export const TranscriptionView = () => {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{/* Loading State */}
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
{isLoadingTranscriptions ? (
|
||||||
<FileAudio className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
||||||
<p>{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}</p>
|
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
</div>
|
<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">
|
{filteredTranscriptions.length === 0 ? (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="glass-effect rounded-xl p-12 text-center">
|
||||||
<div className="flex-1 min-w-0">
|
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||||
<h4 className="font-medium truncate">{item.fileName}</h4>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
|
||||||
{new Date(item.timestamp).toLocaleDateString('pt-BR')} às{' '}
|
</p>
|
||||||
{new Date(item.timestamp).toLocaleTimeString('pt-BR')}
|
</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"
|
||||||
|
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{item.transcription_text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
))}
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{item.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* Pagination */}
|
||||||
)}
|
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
|
||||||
</div>
|
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+165
-280
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { formatDistanceToNow } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +38,7 @@ interface ChatSidebarProps {
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelectChat?: (chat: StoredChat) => void;
|
onSelectChat?: (chat: ChatRecord) => void;
|
||||||
currentChatId?: string;
|
currentChatId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +46,19 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
|
|
||||||
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
||||||
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
|
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [editingFolder, setEditingFolder] = useState<StoredFolder | null>(null);
|
const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
|
||||||
const [deletingFolder, setDeletingFolder] = useState<StoredFolder | null>(null);
|
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||||
const [deletingChat, setDeletingChat] = useState<StoredChat | null>(null);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Estado carregado do localStorage via chatService
|
// Estado carregado do banco de dados via chatService
|
||||||
const [chats, setChats] = useState<StoredChat[]>([]);
|
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||||
const [folders, setFolders] = useState<StoredFolder[]>([]);
|
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
loadChatsAndFolders();
|
loadChatsAndFolders();
|
||||||
|
|
||||||
@@ -76,28 +75,39 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChatsAndFolders = () => {
|
const loadChatsAndFolders = async () => {
|
||||||
const loadedChats = chatService.getAllChats();
|
setIsLoading(true);
|
||||||
const loadedFolders = chatService.getAllFolders();
|
try {
|
||||||
|
const data = await chatService.getChatsAndFolders();
|
||||||
|
|
||||||
setChats(loadedChats);
|
console.log('Dados carregados:', data);
|
||||||
setFolders(loadedFolders);
|
|
||||||
|
|
||||||
// Expande todas as pastas por padrão
|
setChats(data.chats);
|
||||||
setExpandedFolders(new Set(loadedFolders.map(f => f.id)));
|
setFolders(data.folders);
|
||||||
|
|
||||||
|
// Expande todas as pastas por padrão
|
||||||
|
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()) {
|
if (newFolderName.trim()) {
|
||||||
try {
|
try {
|
||||||
const newFolder: StoredFolder = {
|
await chatService.createFolder(newFolderName);
|
||||||
id: chatService.generateChatId(), // Usa mesmo gerador de ID
|
await loadChatsAndFolders();
|
||||||
name: newFolderName,
|
|
||||||
createdAt: new Date(),
|
|
||||||
chatIds: [],
|
|
||||||
};
|
|
||||||
chatService.saveFolder(newFolder);
|
|
||||||
loadChatsAndFolders();
|
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
setIsCreateFolderOpen(false);
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
@@ -105,82 +115,41 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
title: "Pasta criada",
|
title: "Pasta criada",
|
||||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error('Erro ao criar pasta:', error);
|
||||||
toast({
|
toast({
|
||||||
title: "Erro ao criar pasta",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditFolder = () => {
|
const handleDeleteFolder = async () => {
|
||||||
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 = () => {
|
|
||||||
if (deletingFolder) {
|
if (deletingFolder) {
|
||||||
try {
|
try {
|
||||||
// Remove chats da pasta (volta para "Sem Pasta")
|
await chatService.deleteFolder(deletingFolder.id);
|
||||||
const updatedChats = chats.map(chat => {
|
await loadChatsAndFolders();
|
||||||
if (chat.folderId === deletingFolder.id) {
|
|
||||||
const updated = { ...chat, folderId: undefined };
|
|
||||||
chatService.saveChat(updated);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
return chat;
|
|
||||||
});
|
|
||||||
|
|
||||||
chatService.deleteFolder(deletingFolder.id);
|
|
||||||
loadChatsAndFolders();
|
|
||||||
setDeletingFolder(null);
|
setDeletingFolder(null);
|
||||||
setIsDeleteFolderOpen(false);
|
setIsDeleteFolderOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Pasta excluída",
|
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({
|
toast({
|
||||||
title: "Erro ao excluir pasta",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditFolder = (folder: StoredFolder) => {
|
const openDeleteFolder = (folder: FolderRecord) => {
|
||||||
setEditingFolder(folder);
|
|
||||||
setNewFolderName(folder.name);
|
|
||||||
setIsEditFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeleteFolder = (folder: StoredFolder) => {
|
|
||||||
setDeletingFolder(folder);
|
setDeletingFolder(folder);
|
||||||
setIsDeleteFolderOpen(true);
|
setIsDeleteFolderOpen(true);
|
||||||
};
|
};
|
||||||
@@ -195,62 +164,31 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
setExpandedFolders(newExpanded);
|
setExpandedFolders(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveToFolder = (chatId: string, folderId: string) => {
|
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||||
try {
|
try {
|
||||||
const chat = chats.find(c => c.id === chatId);
|
await chatService.moveChatToFolder(chatId, folderId);
|
||||||
if (chat) {
|
await loadChatsAndFolders();
|
||||||
const updatedChat: StoredChat = {
|
|
||||||
...chat,
|
|
||||||
folderId: folderId,
|
|
||||||
};
|
|
||||||
chatService.saveChat(updatedChat);
|
|
||||||
loadChatsAndFolders();
|
|
||||||
|
|
||||||
const folder = folders.find(f => f.id === folderId);
|
const folder = folders.find(f => f.id === folderId);
|
||||||
toast({
|
toast({
|
||||||
title: "Chat movido",
|
title: "Chat movido",
|
||||||
description: `Movido para a pasta "${folder?.name}".`,
|
description: `Movido para a pasta "${folder?.name}".`,
|
||||||
});
|
});
|
||||||
}
|
} catch (error: any) {
|
||||||
} catch (error) {
|
console.error('Erro ao mover chat:', error);
|
||||||
toast({
|
toast({
|
||||||
title: "Erro ao mover chat",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromFolder = (chatId: string) => {
|
const handleDeleteChat = async () => {
|
||||||
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 = () => {
|
|
||||||
if (deletingChat) {
|
if (deletingChat) {
|
||||||
try {
|
try {
|
||||||
chatService.deleteChat(deletingChat.id);
|
await chatService.deleteChat(deletingChat.id);
|
||||||
loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setDeletingChat(null);
|
setDeletingChat(null);
|
||||||
setIsDeleteChatOpen(false);
|
setIsDeleteChatOpen(false);
|
||||||
|
|
||||||
@@ -258,22 +196,23 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
title: "Chat excluído",
|
title: "Chat excluído",
|
||||||
description: "A conversa foi excluída com sucesso.",
|
description: "A conversa foi excluída com sucesso.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error('Erro ao excluir chat:', error);
|
||||||
toast({
|
toast({
|
||||||
title: "Erro ao excluir chat",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteChat = (chat: StoredChat) => {
|
const openDeleteChat = (chat: ChatRecord) => {
|
||||||
setDeletingChat(chat);
|
setDeletingChat(chat);
|
||||||
setIsDeleteChatOpen(true);
|
setIsDeleteChatOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectChat = (chat: StoredChat) => {
|
const handleSelectChat = (chat: ChatRecord) => {
|
||||||
if (onSelectChat) {
|
if (onSelectChat) {
|
||||||
onSelectChat(chat);
|
onSelectChat(chat);
|
||||||
}
|
}
|
||||||
@@ -283,20 +222,17 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
const filteredChats = chats.filter((chat) => {
|
const filteredChats = chats.filter((chat) => {
|
||||||
const searchLower = searchQuery.toLowerCase();
|
const searchLower = searchQuery.toLowerCase();
|
||||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
||||||
const contentMatch = chat.messages.some(msg =>
|
return titleMatch;
|
||||||
msg.content.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
return titleMatch || contentMatch;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separar chats sem pasta
|
// Separar chats sem pasta (folder_id é null)
|
||||||
const chatsWithoutFolder = filteredChats.filter((c) => !c.folderId);
|
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) => {
|
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;
|
return acc;
|
||||||
}, {} as Record<string, StoredChat[]>);
|
}, {} as Record<string, ChatRecord[]>);
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -382,45 +318,13 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit Folder Dialog */}
|
|
||||||
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}>
|
|
||||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Editar Pasta</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Renomeie sua pasta de conversas.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-folder-name">Nome da Pasta</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()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsEditFolderOpen(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}>
|
|
||||||
Salvar
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Folder Alert */}
|
{/* Delete Folder Alert */}
|
||||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -436,57 +340,78 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
{/* Conversations List */}
|
{/* Conversations List */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{/* Folders */}
|
{isLoading ? (
|
||||||
{folders.map((folder) => (
|
<div className="flex items-center justify-center p-8">
|
||||||
<div key={folder.id} className="space-y-1">
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<button
|
) : (
|
||||||
onClick={() => toggleFolder(folder.id)}
|
<>
|
||||||
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
{/* Folders */}
|
||||||
>
|
{folders.map((folder) => (
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
|
<div key={folder.id} className="space-y-1">
|
||||||
<Folder className="w-4 h-4 text-white" />
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFolder(folder.id)}
|
||||||
|
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
|
||||||
|
<Folder className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm flex-1 text-left">
|
||||||
|
{folder.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{chatsByFolder[folder.id]?.length || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 text-destructive"
|
||||||
|
onClick={() => openDeleteFolder(folder)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Excluir Pasta
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm flex-1 text-left">
|
|
||||||
{folder.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{chatsByFolder[folder.id]?.length || 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="ml-6 space-y-1">
|
||||||
<Button
|
{chatsByFolder[folder.id].map((chat) => (
|
||||||
variant="ghost"
|
<ChatItem
|
||||||
size="icon"
|
key={chat.id}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
chat={chat}
|
||||||
>
|
isSelected={currentChatId === chat.id}
|
||||||
<MoreVertical className="w-3 h-3" />
|
onSelect={() => handleSelectChat(chat)}
|
||||||
</Button>
|
folders={folders}
|
||||||
</DropdownMenuTrigger>
|
onMoveToFolder={moveToFolder}
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
onDelete={() => openDeleteChat(chat)}
|
||||||
<DropdownMenuItem
|
/>
|
||||||
className="gap-2"
|
))}
|
||||||
onClick={() => openEditFolder(folder)}
|
</div>
|
||||||
>
|
)}
|
||||||
<Edit className="w-3 h-3" />
|
</div>
|
||||||
Editar Nome
|
))}
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 text-destructive"
|
|
||||||
onClick={() => openDeleteFolder(folder)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
Excluir Pasta
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
{/* Chats without folder */}
|
||||||
<div className="ml-6 space-y-1">
|
{chatsWithoutFolder.length > 0 && (
|
||||||
{chatsByFolder[folder.id].map((chat) => (
|
<div className="space-y-1">
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Sem Pasta
|
||||||
|
</div>
|
||||||
|
{chatsWithoutFolder.map((chat) => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
chat={chat}
|
chat={chat}
|
||||||
@@ -494,34 +419,25 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
onSelect={() => handleSelectChat(chat)}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onRemoveFromFolder={removeFromFolder}
|
|
||||||
onDelete={() => openDeleteChat(chat)}
|
onDelete={() => openDeleteChat(chat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Chats without folder */}
|
{/* Empty state */}
|
||||||
{chatsWithoutFolder.length > 0 && (
|
{!isLoading && chats.length === 0 && (
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
||||||
Sem Pasta
|
<p className="text-sm text-muted-foreground">
|
||||||
</div>
|
Nenhuma conversa ainda
|
||||||
{chatsWithoutFolder.map((chat) => (
|
</p>
|
||||||
<ChatItem
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
key={chat.id}
|
Clique em "Novo Chat" para começar
|
||||||
chat={chat}
|
</p>
|
||||||
isSelected={currentChatId === chat.id}
|
</div>
|
||||||
onSelect={() => handleSelectChat(chat)}
|
)}
|
||||||
folders={folders}
|
</>
|
||||||
onMoveToFolder={moveToFolder}
|
|
||||||
onRemoveFromFolder={removeFromFolder}
|
|
||||||
onDelete={() => openDeleteChat(chat)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -548,12 +464,11 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ChatItemProps {
|
interface ChatItemProps {
|
||||||
chat: StoredChat;
|
chat: ChatRecord;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
folders: StoredFolder[];
|
folders: FolderRecord[];
|
||||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||||
onRemoveFromFolder: (chatId: string) => void;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,50 +478,29 @@ const ChatItem = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
folders,
|
folders,
|
||||||
onMoveToFolder,
|
onMoveToFolder,
|
||||||
onRemoveFromFolder,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ChatItemProps) => {
|
}: ChatItemProps) => {
|
||||||
// Pega a última mensagem do usuário
|
|
||||||
const lastUserMessage = chat.messages
|
|
||||||
.filter(m => m.role === 'user')
|
|
||||||
.slice(-1)[0];
|
|
||||||
|
|
||||||
// Formata timestamp relativo
|
// Formata timestamp relativo
|
||||||
const timeAgo = formatDistanceToNow(new Date(chat.updatedAt), {
|
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: ptBR,
|
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 (
|
return (
|
||||||
<div
|
<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
|
isSelected
|
||||||
? "bg-sidebar-accent cyber-border"
|
? "bg-sidebar-accent cyber-border"
|
||||||
: "hover:bg-muted/50"
|
: "hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
|
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-[180px]">
|
||||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||||
{truncatedTitle}
|
{chat.title}
|
||||||
</p>
|
</p>
|
||||||
{lastUserMessage && (
|
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
||||||
<p className="text-xs text-muted-foreground truncate" title={lastUserMessage.content}>
|
|
||||||
{truncatedMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,17 +516,8 @@ const ChatItem = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||||
{chat.folderId && (
|
{/* Opções para mover para pastas */}
|
||||||
<DropdownMenuItem
|
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
||||||
className="gap-2"
|
|
||||||
onClick={() => onRemoveFromFolder(chat.id)}
|
|
||||||
>
|
|
||||||
<FolderInput className="w-3 h-3" />
|
|
||||||
Remover da Pasta
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ChatMessage } from "./ChatMessage";
|
|||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatSidebar } from "./ChatSidebar";
|
import { ChatSidebar } from "./ChatSidebar";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import { getModelId } from "@/config/models";
|
import { getModelId } from "@/config/models";
|
||||||
|
|
||||||
@@ -38,40 +38,38 @@ export const ChatView = () => {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Salva o chat no localStorage sempre que as mensagens mudam
|
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
||||||
useEffect(() => {
|
// quando enviadas via handleSendMessage
|
||||||
if (messages.length > 1) { // Salva apenas se houver mensagens além da inicial
|
// useEffect(() => {
|
||||||
saveCurrentChat();
|
// if (messages.length > 1) {
|
||||||
// Dispara evento customizado para a sidebar recarregar
|
// saveCurrentChat();
|
||||||
window.dispatchEvent(new Event('chatUpdated'));
|
// window.dispatchEvent(new Event('chatUpdated'));
|
||||||
}
|
// }
|
||||||
}, [messages]);
|
// }, [messages]);
|
||||||
|
|
||||||
// Função para salvar o chat atual
|
// NOTA: Função de salvamento localStorage desabilitada - migrado para banco de dados
|
||||||
const saveCurrentChat = () => {
|
// const saveCurrentChat = () => {
|
||||||
try {
|
// try {
|
||||||
const chatTitle = chatService.generateChatTitle(
|
// const chatTitle = chatService.generateChatTitle(
|
||||||
messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
|
// messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
|
||||||
);
|
// );
|
||||||
|
// const storedChat: StoredChat = {
|
||||||
const storedChat: StoredChat = {
|
// id: currentChatId,
|
||||||
id: currentChatId,
|
// title: chatTitle,
|
||||||
title: chatTitle,
|
// createdAt: new Date(),
|
||||||
createdAt: new Date(),
|
// updatedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
// model: selectedModel,
|
||||||
model: selectedModel,
|
// systemPrompt: systemPrompt,
|
||||||
systemPrompt: systemPrompt,
|
// messages: messages.map(msg => ({
|
||||||
messages: messages.map(msg => ({
|
// ...msg,
|
||||||
...msg,
|
// timestamp: new Date(),
|
||||||
timestamp: new Date(),
|
// })),
|
||||||
})),
|
// };
|
||||||
};
|
// chatService.saveChat(storedChat);
|
||||||
|
// } catch (error) {
|
||||||
chatService.saveChat(storedChat);
|
// console.error('Erro ao salvar chat:', error);
|
||||||
} catch (error) {
|
// }
|
||||||
console.error('Erro ao salvar chat:', error);
|
// };
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewChat = () => {
|
const handleNewChat = () => {
|
||||||
// Reseta para "0" - novo chat sempre começa com chat_id "0"
|
// Reseta para "0" - novo chat sempre começa com chat_id "0"
|
||||||
@@ -89,22 +87,56 @@ export const ChatView = () => {
|
|||||||
window.dispatchEvent(new Event('chatUpdated'));
|
window.dispatchEvent(new Event('chatUpdated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadChat = (chat: StoredChat) => {
|
const handleLoadChat = async (chat: ChatRecord) => {
|
||||||
// Carrega um chat existente do histórico
|
// Carrega um chat existente do banco de dados
|
||||||
setCurrentChatId(chat.id);
|
setCurrentChatId(chat.id);
|
||||||
setSelectedModel(chat.model);
|
|
||||||
setSystemPrompt(chat.systemPrompt);
|
|
||||||
|
|
||||||
// Converte mensagens do StoredChat para Message
|
// Limpa mensagens enquanto carrega
|
||||||
const loadedMessages: Message[] = chat.messages.map(msg => ({
|
setMessages([]);
|
||||||
id: msg.id,
|
setIsLoading(true);
|
||||||
role: msg.role,
|
|
||||||
content: msg.content,
|
|
||||||
model: msg.model,
|
|
||||||
attachments: msg.attachments,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setMessages(loadedMessages);
|
try {
|
||||||
|
// Busca mensagens do chat na API
|
||||||
|
const messagesFromAPI = await chatService.getChatMessages(chat.id);
|
||||||
|
|
||||||
|
console.log('Mensagens carregadas da API:', messagesFromAPI);
|
||||||
|
|
||||||
|
// Converte MessageRecord para Message
|
||||||
|
const loadedMessages: Message[] = messagesFromAPI.map((msg: MessageRecord) => ({
|
||||||
|
id: msg.id,
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
model: undefined, // model_id vem como número, não temos mapeamento reverso
|
||||||
|
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[]) => {
|
const handleSendMessage = async (content: string, files?: File[]) => {
|
||||||
@@ -142,7 +174,18 @@ export const ChatView = () => {
|
|||||||
// Isso mantém o contexto da conversa para as próximas mensagens
|
// Isso mantém o contexto da conversa para as próximas mensagens
|
||||||
if (response.chat_id && response.chat_id !== currentChatId) {
|
if (response.chat_id && response.chat_id !== currentChatId) {
|
||||||
console.log(`Chat ID atualizado: ${currentChatId} → ${response.chat_id}`);
|
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);
|
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 = {
|
const aiResponse: Message = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -14,41 +14,61 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { imageGenerationService, IMAGE_SIZE_OPTIONS, ImageSize } from "@/services/imageGeneration";
|
import {
|
||||||
|
imageGenerationService,
|
||||||
interface GeneratedImage {
|
IMAGE_SIZE_OPTIONS,
|
||||||
id: string;
|
ImageSize,
|
||||||
url: string;
|
ImageRecord
|
||||||
prompt: string;
|
} from "@/services/imageGeneration";
|
||||||
size: ImageSize;
|
import {
|
||||||
timestamp: Date;
|
Pagination,
|
||||||
}
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
export const ImageView = () => {
|
export const ImageView = () => {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
|
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Carrega histórico do localStorage ao montar o componente
|
// Carrega imagens do banco de dados ao montar o componente
|
||||||
useEffect(() => {
|
const loadImages = async (page: number = currentPage, limit: number = perPage) => {
|
||||||
const savedImages = localStorage.getItem('imageHistory');
|
setIsLoadingImages(true);
|
||||||
if (savedImages) {
|
try {
|
||||||
try {
|
const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
|
||||||
const parsedImages = JSON.parse(savedImages);
|
|
||||||
// Converte strings de data de volta para Date objects
|
// Garante que sempre seja um array
|
||||||
const imagesWithDates = parsedImages.map((img: any) => ({
|
if (Array.isArray(fetchedImages)) {
|
||||||
...img,
|
setImages(fetchedImages);
|
||||||
timestamp: new Date(img.timestamp),
|
} else {
|
||||||
}));
|
console.warn('Resposta da API não é um array:', fetchedImages);
|
||||||
setImages(imagesWithDates);
|
setImages([]);
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar histórico de imagens:', error);
|
|
||||||
}
|
}
|
||||||
|
} 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 () => {
|
const handleGenerate = async () => {
|
||||||
// Valida a descrição antes de enviar
|
// Valida a descrição antes de enviar
|
||||||
@@ -73,28 +93,36 @@ export const ImageView = () => {
|
|||||||
|
|
||||||
// Verifica se a geração foi bem-sucedida
|
// Verifica se a geração foi bem-sucedida
|
||||||
if (response.success) {
|
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));
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Imagem gerada com sucesso",
|
title: "Imagem gerada com sucesso",
|
||||||
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
|
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
|
// Limpa o campo de descrição após sucesso
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
|
||||||
|
// Recarrega a lista de imagens (sem aguardar para não bloquear a UI)
|
||||||
|
loadImages(1, perPage);
|
||||||
|
setCurrentPage(1);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Erro ao gerar imagem');
|
throw new Error('Erro ao gerar imagem');
|
||||||
}
|
}
|
||||||
@@ -111,22 +139,41 @@ export const ImageView = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
const newHistory = images.filter((img) => img.id !== id);
|
try {
|
||||||
setImages(newHistory);
|
const result = await imageGenerationService.deleteImage(id);
|
||||||
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
|
|
||||||
|
|
||||||
toast({
|
if (result.success) {
|
||||||
title: "Imagem removida",
|
toast({
|
||||||
description: "A imagem foi removida do histórico.",
|
title: "Imagem removida",
|
||||||
});
|
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 {
|
try {
|
||||||
await imageGenerationService.downloadImage(
|
await imageGenerationService.downloadImage(
|
||||||
image.url,
|
image.image_url,
|
||||||
`${image.prompt.substring(0, 30)}_${image.size}.png`
|
`${image.description.substring(0, 30)}_${image.size}.png`
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -142,9 +189,20 @@ export const ImageView = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredImages = images.filter((img) =>
|
const handlePageChange = (page: number) => {
|
||||||
img.prompt.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
@@ -227,11 +285,11 @@ export const ImageView = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Images Preview */}
|
{/* Recent Images Preview */}
|
||||||
{!isGenerating && images.length > 0 && (
|
{!isGenerating && lastGeneratedImage && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
|
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
|
||||||
<ImageCard
|
<ImageCard
|
||||||
image={images[0]}
|
image={lastGeneratedImage}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
@@ -241,36 +299,102 @@ export const ImageView = () => {
|
|||||||
|
|
||||||
{/* History Tab */}
|
{/* History Tab */}
|
||||||
<TabsContent value="history" className="space-y-4">
|
<TabsContent value="history" className="space-y-4">
|
||||||
{/* Search */}
|
{/* Search and Filters */}
|
||||||
<div className="relative">
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="relative flex-1">
|
||||||
<Input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
value={searchQuery}
|
<Input
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
placeholder="Buscar por descrição..."
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9"
|
placeholder="Buscar por descrição..."
|
||||||
/>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Images Grid */}
|
{/* Loading State */}
|
||||||
{filteredImages.length > 0 ? (
|
{isLoadingImages ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
||||||
{filteredImages.map((image) => (
|
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
<ImageCard
|
<p className="text-muted-foreground">Carregando imagens...</p>
|
||||||
key={image.id}
|
|
||||||
image={image}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="glass-effect rounded-xl p-12 text-center">
|
<>
|
||||||
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
{/* Images Grid */}
|
||||||
<p className="text-muted-foreground">
|
{filteredImages.length > 0 ? (
|
||||||
{searchQuery ? "Nenhuma imagem encontrada" : "Nenhuma imagem gerada ainda"}
|
<>
|
||||||
</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
</div>
|
{filteredImages.map((image) => (
|
||||||
|
<ImageCard
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery ? "Nenhuma imagem encontrada" : "Nenhuma imagem gerada ainda"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -282,9 +406,9 @@ export const ImageView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ImageCardProps {
|
interface ImageCardProps {
|
||||||
image: GeneratedImage;
|
image: ImageRecord;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onDownload: (image: GeneratedImage) => void;
|
onDownload: (image: ImageRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
||||||
@@ -292,9 +416,10 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
|||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
|
|
||||||
// Calcula tempo relativo
|
// Calcula tempo relativo
|
||||||
const getRelativeTime = (timestamp: Date) => {
|
const getRelativeTime = (timestamp: string) => {
|
||||||
const now = new Date();
|
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 minutes = Math.floor(diff / 60000);
|
||||||
const hours = Math.floor(diff / 3600000);
|
const hours = Math.floor(diff / 3600000);
|
||||||
const days = Math.floor(diff / 86400000);
|
const days = Math.floor(diff / 86400000);
|
||||||
@@ -306,13 +431,13 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
console.warn('Erro CORS ao carregar imagem:', image.url);
|
console.warn('Erro CORS ao carregar imagem:', image.image_url);
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
setImageLoading(false);
|
setImageLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
const handleImageLoad = () => {
|
||||||
console.log('Imagem carregada com sucesso:', image.url);
|
console.log('Imagem carregada com sucesso:', image.image_url);
|
||||||
setImageLoading(false);
|
setImageLoading(false);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
};
|
};
|
||||||
@@ -332,7 +457,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>
|
<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">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<a
|
<a
|
||||||
href={image.url}
|
href={image.image_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
|
||||||
@@ -340,7 +465,7 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
|||||||
Abrir imagem em nova aba
|
Abrir imagem em nova aba
|
||||||
</a>
|
</a>
|
||||||
<button
|
<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"
|
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
|
||||||
>
|
>
|
||||||
Copiar URL
|
Copiar URL
|
||||||
@@ -349,8 +474,8 @@ const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={image.image_url}
|
||||||
alt={image.prompt}
|
alt={image.description}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
@@ -360,10 +485,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 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">
|
<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">
|
<div className="flex items-center justify-between text-xs text-white/70">
|
||||||
<span>{IMAGE_SIZE_OPTIONS[image.size].label}</span>
|
<span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
|
||||||
<span>{getRelativeTime(image.timestamp)}</span>
|
<span>{getRelativeTime(image.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ export interface AudioGenerationResponse {
|
|||||||
message: string; // Texto que foi convertido em áudio
|
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
|
* Interface para os dados necessários para geração de áudio
|
||||||
*/
|
*/
|
||||||
@@ -73,6 +90,8 @@ export const VOICE_OPTIONS = {
|
|||||||
*/
|
*/
|
||||||
class AudioGenerationService {
|
class AudioGenerationService {
|
||||||
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
|
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
|
* Gera um arquivo de áudio a partir de texto
|
||||||
@@ -192,6 +211,149 @@ class AudioGenerationService {
|
|||||||
|
|
||||||
return { valid: true };
|
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)
|
// Exporta instância única (Singleton)
|
||||||
|
|||||||
+432
-4
@@ -59,6 +59,54 @@ export interface StoredFolder {
|
|||||||
chatIds: string[];
|
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;
|
||||||
|
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;
|
||||||
|
has_attachments: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serviço de chat com IA
|
* Serviço de chat com IA
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +115,14 @@ class ChatService {
|
|||||||
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
|
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
|
||||||
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
|
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)
|
// Formatos de arquivo permitidos (atualmente)
|
||||||
private readonly ALLOWED_FILE_TYPES = {
|
private readonly ALLOWED_FILE_TYPES = {
|
||||||
// Formatos ativos
|
// Formatos ativos
|
||||||
@@ -280,11 +336,11 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deleta um chat
|
* Deleta um chat do localStorage
|
||||||
*
|
*
|
||||||
* @param chatId - ID do chat a ser deletado
|
* @param chatId - ID do chat a ser deletado
|
||||||
*/
|
*/
|
||||||
deleteChat(chatId: string): void {
|
deleteChatLocal(chatId: string): void {
|
||||||
try {
|
try {
|
||||||
const chats = this.getAllChats();
|
const chats = this.getAllChats();
|
||||||
const filteredChats = chats.filter(c => c.id !== chatId);
|
const filteredChats = chats.filter(c => c.id !== chatId);
|
||||||
@@ -342,11 +398,11 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deleta uma pasta
|
* Deleta uma pasta do localStorage
|
||||||
*
|
*
|
||||||
* @param folderId - ID da pasta a ser deletada
|
* @param folderId - ID da pasta a ser deletada
|
||||||
*/
|
*/
|
||||||
deleteFolder(folderId: string): void {
|
deleteFolderLocal(folderId: string): void {
|
||||||
try {
|
try {
|
||||||
const folders = this.getAllFolders();
|
const folders = this.getAllFolders();
|
||||||
const filteredFolders = folders.filter(f => f.id !== folderId);
|
const filteredFolders = folders.filter(f => f.id !== folderId);
|
||||||
@@ -476,6 +532,378 @@ class ChatService {
|
|||||||
getMaxAttachments(): number {
|
getMaxAttachments(): number {
|
||||||
return this.MAX_ATTACHMENTS;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
// Exporta instância única (Singleton)
|
||||||
|
|||||||
@@ -16,6 +16,35 @@ export interface ImageGenerationResponse {
|
|||||||
message: string; // Descrição original
|
message: string; // Descrição original
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface para os dados necessários para geração de imagem
|
* Interface para os dados necessários para geração de imagem
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +84,8 @@ export const IMAGE_SIZE_OPTIONS = {
|
|||||||
*/
|
*/
|
||||||
class ImageGenerationService {
|
class ImageGenerationService {
|
||||||
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
|
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
|
* Gera uma imagem a partir de uma descrição em texto
|
||||||
@@ -213,6 +244,150 @@ class ImageGenerationService {
|
|||||||
throw new Error('Não foi possível baixar a imagem');
|
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)
|
// Exporta instância única (Singleton)
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export interface TranscriptionResponse {
|
|||||||
message: string;
|
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
|
* Interface para os dados necessários para transcrição
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +41,8 @@ export interface TranscriptionRequest {
|
|||||||
*/
|
*/
|
||||||
class TranscriptionService {
|
class TranscriptionService {
|
||||||
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
|
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
|
* Transcreve um arquivo de áudio
|
||||||
@@ -105,6 +123,149 @@ class TranscriptionService {
|
|||||||
|
|
||||||
return { valid: true };
|
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)
|
// Exporta instância única (Singleton)
|
||||||
|
|||||||
Reference in New Issue
Block a user