Primeiro Commit

This commit is contained in:
luisfepsale
2025-10-22 12:52:23 -03:00
parent 27820254e7
commit 235fb538e1
19 changed files with 2132 additions and 426 deletions
+13 -6
View File
@@ -25,21 +25,28 @@ interface ChatHeaderProps {
onSystemPromptChange?: (prompt: string) => void;
}
export const ChatHeader = ({
export const ChatHeader = ({
showModelSelector = false,
selectedModel = "ChatGPT 4.1",
selectedModel = "GPT-4o",
onModelChange,
systemPrompt = "Você é um assistente útil e prestativo.",
onSystemPromptChange
}: ChatHeaderProps) => {
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
const models = [
"ChatGPT 4.1",
"ChatGPT 5",
"GPT-4.1",
"GPT-4o",
"GPT-5 Mini",
"Gemini 2.0 Flash",
"Claude Sonnet 4.5",
"Claude Opus 4.1",
"DeepSeek V3.2 Chat",
"DeepSeek V3.2 Reasoner",
"Gemini 2.5 Flash",
"Gemini 2.5 Flash-Lite",
"Claude Haiku 4.5",
"GPT-4o Mini",
];
const handleSavePersonality = () => {
+74 -67
View File
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { audioGenerationService, VOICE_OPTIONS, VoiceType } from "@/services/audioGeneration";
interface GeneratedAudio {
id: string;
@@ -24,49 +25,10 @@ interface GeneratedAudio {
timestamp: Date;
}
const VOICE_OPTIONS = {
alloy: {
label: "Alloy",
gender: "Masculina",
style: "Neutra, equilibrada, tom corporativo",
description: "Boa para tutoriais e comunicações institucionais."
},
echo: {
label: "Echo",
gender: "Masculina",
style: "Forte e profissional, mais grave",
description: "Ideal para voz de autoridade ou locução firme."
},
fable: {
label: "Fable",
gender: "Feminina",
style: "Narrativa, calorosa e envolvente",
description: "Ótima para storytelling e áudios empáticos."
},
onyx: {
label: "Onyx",
gender: "Masculina",
style: "Grave, autoritária, impactante",
description: "Excelente para trailers, mensagens sérias ou institucionais."
},
nova: {
label: "Nova",
gender: "Feminina",
style: "Brilhante, animada, energética",
description: "Boa para vídeos curtos, marketing ou conteúdos leves."
},
shimmer: {
label: "Shimmer",
gender: "Feminina",
style: "Suave, otimista, clara",
description: "Boa para mensagens acolhedoras, explicações e IA conversacional."
}
};
export const GenerationView = () => {
const [textToSpeech, setTextToSpeech] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<keyof typeof VOICE_OPTIONS>("alloy");
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
const [generatedAudio, setGeneratedAudio] = useState<GeneratedAudio | null>(null);
const [audioHistory, setAudioHistory] = useState<GeneratedAudio[]>([]);
const [audioSearchQuery, setAudioSearchQuery] = useState("");
@@ -79,32 +41,67 @@ export const GenerationView = () => {
}
}, []);
const handleGenerateAudio = () => {
setIsProcessing(true);
setTimeout(() => {
const audio: GeneratedAudio = {
id: Date.now().toString(),
text: textToSpeech,
voice: selectedVoice,
voiceLabel: VOICE_OPTIONS[selectedVoice].label,
audioUrl: "data:audio/mp3;base64,//sample",
timestamp: new Date(),
};
setGeneratedAudio(audio);
const newHistory = [audio, ...audioHistory].slice(0, 10);
setAudioHistory(newHistory);
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
setIsProcessing(false);
const handleGenerateAudio = async () => {
// Valida o texto antes de enviar
const validation = audioGenerationService.validateText(textToSpeech);
if (!validation.valid) {
toast({
title: "Áudio gerado com sucesso",
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
title: "Texto inválido",
description: validation.error,
variant: "destructive",
});
}, 2000);
return;
}
setIsProcessing(true);
try {
// Chama o serviço de geração de áudio
const response = await audioGenerationService.generateAudio({
message: textToSpeech,
voice: selectedVoice,
});
// Verifica se a geração foi bem-sucedida
if (response.success) {
console.log('URL do áudio gerado:', response.audio_url);
const audio: GeneratedAudio = {
id: response.audio_generation_id,
text: response.message,
voice: selectedVoice,
voiceLabel: VOICE_OPTIONS[selectedVoice].label,
audioUrl: response.audio_url,
timestamp: new Date(),
};
setGeneratedAudio(audio);
const newHistory = [audio, ...audioHistory].slice(0, 10);
setAudioHistory(newHistory);
localStorage.setItem('audioHistory', JSON.stringify(newHistory));
toast({
title: "Áudio gerado com sucesso",
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
});
// Limpa o campo de texto após sucesso
setTextToSpeech("");
} else {
throw new Error('Erro ao gerar áudio');
}
} catch (error: any) {
console.error('Erro na geração de áudio:', error);
toast({
title: "Erro na geração",
description: error.message || "Não foi possível gerar o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
};
const handleDownloadAudio = (audio: GeneratedAudio) => {
@@ -177,7 +174,7 @@ export const GenerationView = () => {
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de Voz</label>
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as keyof typeof VOICE_OPTIONS)}>
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as VoiceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -258,7 +255,12 @@ export const GenerationView = () => {
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm mb-3">{generatedAudio.text}</p>
<audio controls className="w-full">
<audio
key={generatedAudio.id}
controls
className="w-full"
preload="metadata"
>
<source src={generatedAudio.audioUrl} type="audio/mpeg" />
Seu navegador não suporta o elemento de áudio.
</audio>
@@ -325,7 +327,12 @@ export const GenerationView = () => {
<p className="text-sm text-muted-foreground line-clamp-2">
{audio.text}
</p>
<audio controls className="w-full">
<audio
key={audio.id}
controls
className="w-full"
preload="metadata"
>
<source src={audio.audioUrl} type="audio/mpeg" />
</audio>
</div>
+47 -34
View File
@@ -6,6 +6,7 @@ import { Upload, Download, Trash2, FileAudio, Copy, Search } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { transcriptionService } from "@/services/transcription";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
@@ -15,6 +16,7 @@ interface TranscriptionResult {
fileName: string;
text: string;
timestamp: Date;
audioUrl?: string;
}
export const TranscriptionView = () => {
@@ -36,20 +38,12 @@ export const TranscriptionView = () => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
// Valida o arquivo usando o serviço
const validation = transcriptionService.validateAudioFile(file);
if (!validation.valid) {
toast({
title: "Arquivo muito grande",
description: "O arquivo deve ter no máximo 25 MB",
variant: "destructive",
});
return;
}
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
toast({
title: "Formato não suportado",
description: `Formatos suportados: ${SUPPORTED_FORMATS.join(', ')}`,
title: "Arquivo inválido",
description: validation.error,
variant: "destructive",
});
return;
@@ -61,28 +55,47 @@ export const TranscriptionView = () => {
const handleTranscription = async (file: File) => {
setIsTranscribing(true);
setTimeout(() => {
const result: TranscriptionResult = {
id: Date.now().toString(),
fileName: file.name,
text: "Esta é uma transcrição de exemplo do áudio enviado. O sistema utilizará modelos de IA avançados para converter sua fala em texto com alta precisão. O áudio foi processado com sucesso e convertido para texto.",
timestamp: new Date(),
};
setTranscriptionResult(result);
const newHistory = [result, ...transcriptionHistory].slice(0, 10);
setTranscriptionHistory(newHistory);
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
setIsTranscribing(false);
toast({
title: "Transcrição concluída",
description: "Seu áudio foi transcrito com sucesso!",
try {
// Chama o serviço de transcrição
const response = await transcriptionService.transcribeAudio({
audioFile: file,
});
}, 2000);
// Verifica se a transcrição foi bem-sucedida
if (response.success) {
const result: TranscriptionResult = {
id: response.transcription_id,
fileName: file.name,
text: response.message,
timestamp: new Date(),
audioUrl: response.audio_url,
};
setTranscriptionResult(result);
const newHistory = [result, ...transcriptionHistory].slice(0, 10);
setTranscriptionHistory(newHistory);
localStorage.setItem('transcriptionHistory', JSON.stringify(newHistory));
toast({
title: "Transcrição concluída",
description: "Seu áudio foi transcrito com sucesso!",
});
} else {
throw new Error(response.message || 'Erro ao transcrever áudio');
}
} catch (error: any) {
console.error('Erro na transcrição:', error);
toast({
title: "Erro na transcrição",
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsTranscribing(false);
}
};
const handleDeleteTranscription = () => {
+44 -9
View File
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState, useRef, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { chatService } from "@/services/chat";
interface ChatInputProps {
onSendMessage: (message: string, files?: File[]) => void;
@@ -67,21 +68,54 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
// Validate file size (max 10MB per file)
const validFiles = files.filter((file) => {
const newFiles = [...attachedFiles, ...files];
// Valida quantidade máxima de anexos (5)
if (newFiles.length > chatService.getMaxAttachments()) {
toast({
title: "Limite de anexos excedido",
description: `Você pode anexar no máximo ${chatService.getMaxAttachments()} arquivos por mensagem.`,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Valida tipos de arquivo e tamanho
const validFiles: File[] = [];
for (const file of files) {
// Valida tamanho (max 10MB por arquivo)
if (file.size > 10 * 1024 * 1024) {
toast({
title: "Arquivo muito grande",
description: `${file.name} excede o limite de 10MB`,
variant: "destructive",
});
return false;
continue;
}
return true;
});
setAttachedFiles([...attachedFiles, ...validFiles]);
validFiles.push(file);
}
// Valida formatos permitidos
const allFiles = [...attachedFiles, ...validFiles];
const validation = chatService.validateAttachments(allFiles);
if (!validation.valid) {
toast({
title: "Formato de arquivo não permitido",
description: validation.error,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setAttachedFiles(allFiles);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
@@ -156,7 +190,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
multiple
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp"
accept={chatService.getAllowedFileExtensions()}
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
/>
<Button
variant="outline"
@@ -179,7 +214,7 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
</div>
<p className="hidden md:block text-xs text-muted-foreground text-center">
Pressione Enter para enviar, Shift + Enter para nova linha Máx. 10MB por arquivo
Pressione Enter para enviar, Shift + Enter para nova linha Máx. {chatService.getMaxAttachments()} anexos Formatos: {chatService.getAllowedFileTypesLabel()}
</p>
</div>
</div>
+300 -134
View File
@@ -1,8 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { chatService, StoredChat, StoredFolder } from "@/services/chat";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,111 +34,153 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
folderId?: string;
}
interface ChatFolder {
id: string;
name: string;
}
interface ChatSidebarProps {
isCollapsed: boolean;
onToggleCollapse: () => void;
onNewChat: () => void;
onSelectChat?: (chat: StoredChat) => void;
currentChatId?: string;
}
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => {
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
const [editingFolder, setEditingFolder] = useState<StoredFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<StoredFolder | null>(null);
const [deletingChat, setDeletingChat] = useState<StoredChat | null>(null);
const [folders, setFolders] = useState<ChatFolder[]>([
{ id: "work", name: "Trabalho" },
{ id: "personal", name: "Pessoal" },
]);
// Estado carregado do localStorage via chatService
const [chats, setChats] = useState<StoredChat[]>([]);
const [folders, setFolders] = useState<StoredFolder[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [conversations, setConversations] = useState<Conversation[]>([
{
id: "1",
title: "Conversa sobre IA",
lastMessage: "Como funciona o machine learning?",
timestamp: "Há 5 min",
},
{
id: "2",
title: "Projeto HGTX",
lastMessage: "Discutindo arquitetura do sistema",
timestamp: "Há 1 hora",
folderId: "work",
},
{
id: "3",
title: "Ideias criativas",
lastMessage: "Brainstorm para novo produto",
timestamp: "Ontem",
folderId: "personal",
},
]);
// Carrega chats e pastas do localStorage quando o componente monta
useEffect(() => {
loadChatsAndFolders();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
// Escuta evento customizado para recarregar quando chat é atualizado
const handleChatUpdate = () => {
loadChatsAndFolders();
};
window.addEventListener('chatUpdated', handleChatUpdate);
// Cleanup
return () => {
window.removeEventListener('chatUpdated', handleChatUpdate);
};
}, []);
const loadChatsAndFolders = () => {
const loadedChats = chatService.getAllChats();
const loadedFolders = chatService.getAllFolders();
setChats(loadedChats);
setFolders(loadedFolders);
// Expande todas as pastas por padrão
setExpandedFolders(new Set(loadedFolders.map(f => f.id)));
};
const handleCreateFolder = () => {
if (newFolderName.trim()) {
const newFolder: ChatFolder = {
id: Date.now().toString(),
name: newFolderName,
};
setFolders([...folders, newFolder]);
setNewFolderName("");
setIsCreateFolderOpen(false);
try {
const newFolder: StoredFolder = {
id: chatService.generateChatId(), // Usa mesmo gerador de ID
name: newFolderName,
createdAt: new Date(),
chatIds: [],
};
chatService.saveFolder(newFolder);
loadChatsAndFolders();
setNewFolderName("");
setIsCreateFolderOpen(false);
toast({
title: "Pasta criada",
description: `Pasta "${newFolderName}" criada com sucesso.`,
});
} catch (error) {
toast({
title: "Erro ao criar pasta",
description: "Não foi possível criar a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleEditFolder = () => {
if (editingFolder && newFolderName.trim()) {
setFolders(
folders.map((f) =>
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
)
);
setNewFolderName("");
setEditingFolder(null);
setIsEditFolderOpen(false);
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) {
// Remove folder and move conversations back to "no folder"
setConversations(
conversations.map((conv) =>
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
)
);
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
try {
// Remove chats da pasta (volta para "Sem Pasta")
const updatedChats = chats.map(chat => {
if (chat.folderId === deletingFolder.id) {
const updated = { ...chat, folderId: undefined };
chatService.saveChat(updated);
return updated;
}
return chat;
});
chatService.deleteFolder(deletingFolder.id);
loadChatsAndFolders();
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
toast({
title: "Pasta excluída",
description: "As conversas foram movidas para 'Sem Pasta'.",
});
} catch (error) {
toast({
title: "Erro ao excluir pasta",
description: "Não foi possível excluir a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const openEditFolder = (folder: ChatFolder) => {
const openEditFolder = (folder: StoredFolder) => {
setEditingFolder(folder);
setNewFolderName(folder.name);
setIsEditFolderOpen(true);
};
const openDeleteFolder = (folder: ChatFolder) => {
const openDeleteFolder = (folder: StoredFolder) => {
setDeletingFolder(folder);
setIsDeleteFolderOpen(true);
};
@@ -149,37 +195,112 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
setExpandedFolders(newExpanded);
};
const moveToFolder = (conversationId: string, folderId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId } : conv
)
);
const moveToFolder = (chatId: string, folderId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: folderId,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
const folder = folders.find(f => f.id === folderId);
toast({
title: "Chat movido",
description: `Movido para a pasta "${folder?.name}".`,
});
}
} catch (error) {
toast({
title: "Erro ao mover chat",
description: "Não foi possível mover o chat. Tente novamente.",
variant: "destructive",
});
}
};
const removeFromFolder = (conversationId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
)
);
const removeFromFolder = (chatId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: undefined,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
toast({
title: "Chat removido da pasta",
description: "Chat movido para 'Sem Pasta'.",
});
}
} catch (error) {
toast({
title: "Erro ao remover chat",
description: "Não foi possível remover o chat da pasta.",
variant: "destructive",
});
}
};
const filteredConversations = conversations.filter(
(conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDeleteChat = () => {
if (deletingChat) {
try {
chatService.deleteChat(deletingChat.id);
loadChatsAndFolders();
setDeletingChat(null);
setIsDeleteChatOpen(false);
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
const conversationsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
toast({
title: "Chat excluído",
description: "A conversa foi excluída com sucesso.",
});
} catch (error) {
toast({
title: "Erro ao excluir chat",
description: "Não foi possível excluir o chat. Tente novamente.",
variant: "destructive",
});
}
}
};
const openDeleteChat = (chat: StoredChat) => {
setDeletingChat(chat);
setIsDeleteChatOpen(true);
};
const handleSelectChat = (chat: StoredChat) => {
if (onSelectChat) {
onSelectChat(chat);
}
};
// Filtrar chats pela busca
const filteredChats = chats.filter((chat) => {
const searchLower = searchQuery.toLowerCase();
const titleMatch = chat.title.toLowerCase().includes(searchLower);
const contentMatch = chat.messages.some(msg =>
msg.content.toLowerCase().includes(searchLower)
);
return titleMatch || contentMatch;
});
// Separar chats sem pasta
const chatsWithoutFolder = filteredChats.filter((c) => !c.folderId);
// Agrupar chats por pasta
const chatsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folderId === folder.id);
return acc;
}, {} as Record<string, Conversation[]>);
}, {} as Record<string, StoredChat[]>);
if (isCollapsed) {
return (
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<Button
variant="ghost"
size="icon"
@@ -193,7 +314,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
}
return (
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
{/* Header with collapse button */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
@@ -330,10 +451,10 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{conversationsByFolder[folder.id]?.length || 0}
{chatsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -363,17 +484,18 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
</DropdownMenu>
</div>
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
<div className="ml-6 space-y-1">
{conversationsByFolder[folder.id].map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
{chatsByFolder[folder.id].map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
@@ -381,48 +503,90 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
</div>
))}
{/* Conversations without folder */}
{conversationsWithoutFolder.length > 0 && (
{/* Chats without folder */}
{chatsWithoutFolder.length > 0 && (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Sem Pasta
</div>
{conversationsWithoutFolder.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
{chatsWithoutFolder.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
)}
</div>
</ScrollArea>
{/* Delete Chat Alert */}
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50">
<AlertDialogHeader>
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
interface ConversationItemProps {
conversation: Conversation;
interface ChatItemProps {
chat: StoredChat;
isSelected: boolean;
onSelect: () => void;
folders: ChatFolder[];
onMoveToFolder: (conversationId: string, folderId: string) => void;
onRemoveFromFolder: (conversationId: string) => void;
folders: StoredFolder[];
onMoveToFolder: (chatId: string, folderId: string) => void;
onRemoveFromFolder: (chatId: string) => void;
onDelete: () => void;
}
const ConversationItem = ({
conversation,
const ChatItem = ({
chat,
isSelected,
onSelect,
folders,
onMoveToFolder,
onRemoveFromFolder,
}: ConversationItemProps) => {
onDelete,
}: ChatItemProps) => {
// Pega a última mensagem do usuário
const lastUserMessage = chat.messages
.filter(m => m.role === 'user')
.slice(-1)[0];
// Formata timestamp relativo
const timeAgo = formatDistanceToNow(new Date(chat.updatedAt), {
addSuffix: true,
locale: ptBR,
});
// Limita o título a 40 caracteres
const truncatedTitle = chat.title.length > 40
? chat.title.substring(0, 40) + '...'
: chat.title;
// Limita a mensagem a 50 caracteres
const truncatedMessage = lastUserMessage?.content.length > 50
? lastUserMessage.content.substring(0, 50) + '...'
: lastUserMessage?.content;
return (
<div
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
@@ -433,13 +597,17 @@ const ConversationItem = ({
onClick={onSelect}
>
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{conversation.title}</p>
<p className="text-xs text-muted-foreground truncate">
{conversation.lastMessage}
<div className="flex-1 min-w-0 overflow-hidden">
<p className="text-sm font-medium truncate" title={chat.title}>
{truncatedTitle}
</p>
{lastUserMessage && (
<p className="text-xs text-muted-foreground truncate" title={lastUserMessage.content}>
{truncatedMessage}
</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{conversation.timestamp}
{timeAgo}
</p>
</div>
@@ -448,21 +616,16 @@ const ConversationItem = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem className="gap-2">
<Edit className="w-3 h-3" />
Renomear
</DropdownMenuItem>
{conversation.folderId && (
{chat.folderId && (
<DropdownMenuItem
className="gap-2"
onClick={() => onRemoveFromFolder(conversation.id)}
onClick={() => onRemoveFromFolder(chat.id)}
>
<FolderInput className="w-3 h-3" />
Remover da Pasta
@@ -473,14 +636,17 @@ const ConversationItem = ({
<DropdownMenuItem
key={folder.id}
className="gap-2"
onClick={() => onMoveToFolder(conversation.id, folder.id)}
onClick={() => onMoveToFolder(chat.id, folder.id)}
>
<Folder className="w-3 h-3" />
Mover para {folder.name}
</DropdownMenuItem>
))}
<DropdownMenuItem className="gap-2 text-destructive">
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={onDelete}
>
<Trash2 className="w-3 h-3" />
Excluir
</DropdownMenuItem>
+120 -15
View File
@@ -1,9 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { chatService, StoredChat } from "@/services/chat";
import { useToast } from "@/hooks/use-toast";
import { getModelId } from "@/config/models";
interface Message {
id: string;
@@ -18,24 +21,61 @@ interface Message {
}
export const ChatView = () => {
const { toast } = useToast();
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1");
const [currentChatId, setCurrentChatId] = useState("1");
const [selectedModel, setSelectedModel] = useState("GPT-4o");
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
const [currentChatId, setCurrentChatId] = useState("0");
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: "ChatGPT 4.1",
model: "GPT-4o",
},
]);
const [isLoading, setIsLoading] = useState(false);
// Salva o chat no localStorage sempre que as mensagens mudam
useEffect(() => {
if (messages.length > 1) { // Salva apenas se houver mensagens além da inicial
saveCurrentChat();
// Dispara evento customizado para a sidebar recarregar
window.dispatchEvent(new Event('chatUpdated'));
}
}, [messages]);
// Função para salvar o chat atual
const saveCurrentChat = () => {
try {
const chatTitle = chatService.generateChatTitle(
messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
);
const storedChat: StoredChat = {
id: currentChatId,
title: chatTitle,
createdAt: new Date(),
updatedAt: new Date(),
model: selectedModel,
systemPrompt: systemPrompt,
messages: messages.map(msg => ({
...msg,
timestamp: new Date(),
})),
};
chatService.saveChat(storedChat);
} catch (error) {
console.error('Erro ao salvar chat:', error);
}
};
const handleNewChat = () => {
const newChatId = Date.now().toString();
setCurrentChatId(newChatId);
// Reseta para "0" - novo chat sempre começa com chat_id "0"
setCurrentChatId("0");
setMessages([
{
id: "1",
@@ -44,9 +84,31 @@ export const ChatView = () => {
model: selectedModel,
},
]);
// Dispara evento para atualizar a sidebar
window.dispatchEvent(new Event('chatUpdated'));
};
const handleSendMessage = (content: string, files?: File[]) => {
const handleLoadChat = (chat: StoredChat) => {
// Carrega um chat existente do histórico
setCurrentChatId(chat.id);
setSelectedModel(chat.model);
setSystemPrompt(chat.systemPrompt);
// Converte mensagens do StoredChat para Message
const loadedMessages: Message[] = chat.messages.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content,
model: msg.model,
attachments: msg.attachments,
}));
setMessages(loadedMessages);
};
const handleSendMessage = async (content: string, files?: File[]) => {
// Adiciona mensagem do usuário na interface
const newMessage: Message = {
id: Date.now().toString(),
role: "user",
@@ -61,28 +123,71 @@ export const ChatView = () => {
setMessages((prev) => [...prev, newMessage]);
setIsLoading(true);
// Simulate AI response
setTimeout(() => {
const aiResponse: Message = {
try {
// Envia mensagem para a API
// Converte o nome do modelo para o ID da API
const modelId = getModelId(selectedModel);
const response = await chatService.sendMessage({
chatId: currentChatId,
message: content,
modelId: modelId,
personalidade: systemPrompt,
anexos: files,
});
// Adiciona resposta da IA na interface
if (response.success) {
// IMPORTANTE: Atualiza o chat_id com o valor retornado pela API
// Isso mantém o contexto da conversa para as próximas mensagens
if (response.chat_id && response.chat_id !== currentChatId) {
console.log(`Chat ID atualizado: ${currentChatId}${response.chat_id}`);
setCurrentChatId(response.chat_id);
}
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: response.response,
model: selectedModel,
};
setMessages((prev) => [...prev, aiResponse]);
} else {
throw new Error('Resposta inválida da API');
}
} catch (error: any) {
console.error('Erro ao enviar mensagem:', error);
// Mostra toast de erro para o usuário
toast({
title: "Erro ao enviar mensagem",
description: error.message || "Não foi possível comunicar com a IA. Tente novamente.",
variant: "destructive",
});
// Adiciona mensagem de erro na interface
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content:
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
content: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
model: selectedModel,
};
setMessages((prev) => [...prev, aiResponse]);
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}, 1500);
}
};
return (
<div className="flex h-full w-full pb-16 md:pb-0">
{/* Chat Sidebar - Hidden on mobile */}
<div className="hidden md:block">
<ChatSidebar
<ChatSidebar
isCollapsed={isChatSidebarCollapsed}
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
onNewChat={handleNewChat}
onSelectChat={handleLoadChat}
currentChatId={currentChatId}
/>
</div>
+191 -48
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@@ -13,56 +13,133 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { imageGenerationService, IMAGE_SIZE_OPTIONS, ImageSize } from "@/services/imageGeneration";
interface GeneratedImage {
id: string;
url: string;
prompt: string;
size: string;
timestamp: string;
size: ImageSize;
timestamp: Date;
}
export const ImageView = () => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [selectedSize, setSelectedSize] = useState("1024x1024");
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
const [searchQuery, setSearchQuery] = useState("");
const [images, setImages] = useState<GeneratedImage[]>([
{
id: "1",
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
prompt: "Paisagem futurista com cidades voadoras",
size: "1024x1024",
timestamp: "Há 2 horas",
},
{
id: "2",
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop",
prompt: "Robô humanoide em estilo cyberpunk",
size: "1024x1536",
timestamp: "Há 5 horas",
},
]);
const [images, setImages] = useState<GeneratedImage[]>([]);
const { toast } = useToast();
// Carrega histórico do localStorage ao montar o componente
useEffect(() => {
const savedImages = localStorage.getItem('imageHistory');
if (savedImages) {
try {
const parsedImages = JSON.parse(savedImages);
// Converte strings de data de volta para Date objects
const imagesWithDates = parsedImages.map((img: any) => ({
...img,
timestamp: new Date(img.timestamp),
}));
setImages(imagesWithDates);
} catch (error) {
console.error('Erro ao carregar histórico de imagens:', error);
}
}
}, []);
const handleGenerate = async () => {
// Valida a descrição antes de enviar
const validation = imageGenerationService.validateDescription(prompt);
if (!validation.valid) {
toast({
title: "Descrição inválida",
description: validation.error,
variant: "destructive",
});
return;
}
const handleGenerate = () => {
setIsGenerating(true);
// Simulate image generation
setTimeout(() => {
const newImage: GeneratedImage = {
id: Date.now().toString(),
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
prompt: prompt,
try {
// Chama o serviço de geração de imagem
const response = await imageGenerationService.generateImage({
description: prompt,
size: selectedSize,
timestamp: "Agora",
};
setImages([newImage, ...images]);
});
// Verifica se a geração foi bem-sucedida
if (response.success) {
// Log da URL da imagem para debug
console.log('URL da imagem gerada:', response.image_url);
const newImage: GeneratedImage = {
id: response.image_generation_id,
url: response.image_url,
prompt: response.message,
size: selectedSize,
timestamp: new Date(),
};
const newHistory = [newImage, ...images].slice(0, 20); // Mantém apenas as últimas 20 imagens
setImages(newHistory);
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
toast({
title: "Imagem gerada com sucesso",
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
});
// Limpa o campo de descrição após sucesso
setPrompt("");
} else {
throw new Error('Erro ao gerar imagem');
}
} catch (error: any) {
console.error('Erro na geração de imagem:', error);
toast({
title: "Erro na geração",
description: error.message || "Não foi possível gerar a imagem. Tente novamente.",
variant: "destructive",
});
} finally {
setIsGenerating(false);
setPrompt("");
}, 3000);
}
};
const handleDelete = (id: string) => {
setImages(images.filter((img) => img.id !== id));
const newHistory = images.filter((img) => img.id !== id);
setImages(newHistory);
localStorage.setItem('imageHistory', JSON.stringify(newHistory));
toast({
title: "Imagem removida",
description: "A imagem foi removida do histórico.",
});
};
const handleDownload = async (image: GeneratedImage) => {
try {
await imageGenerationService.downloadImage(
image.url,
`${image.prompt.substring(0, 30)}_${image.size}.png`
);
toast({
title: "Download iniciado",
description: "A imagem está sendo baixada.",
});
} catch (error) {
toast({
title: "Erro no download",
description: "Não foi possível baixar a imagem.",
variant: "destructive",
});
}
};
const filteredImages = images.filter((img) =>
@@ -107,14 +184,14 @@ export const ImageView = () => {
<label className="text-sm font-medium text-foreground">
Tamanho
</label>
<Select value={selectedSize} onValueChange={setSelectedSize}>
<Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem>
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem>
<SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
<SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -153,9 +230,10 @@ export const ImageView = () => {
{!isGenerating && images.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
<ImageCard
image={images[0]}
<ImageCard
image={images[0]}
onDelete={handleDelete}
onDownload={handleDownload}
/>
</div>
)}
@@ -178,10 +256,11 @@ export const ImageView = () => {
{filteredImages.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredImages.map((image) => (
<ImageCard
key={image.id}
<ImageCard
key={image.id}
image={image}
onDelete={handleDelete}
onDownload={handleDownload}
/>
))}
</div>
@@ -205,29 +284,93 @@ export const ImageView = () => {
interface ImageCardProps {
image: GeneratedImage;
onDelete: (id: string) => void;
onDownload: (image: GeneratedImage) => void;
}
const ImageCard = ({ image, onDelete }: ImageCardProps) => {
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Calcula tempo relativo
const getRelativeTime = (timestamp: Date) => {
const now = new Date();
const diff = now.getTime() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Agora';
if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
if (hours < 24) return `${hours} ${hours === 1 ? 'hora' : 'horas'}`;
return `${days} ${days === 1 ? 'dia' : 'dias'}`;
};
const handleImageError = () => {
console.warn('Erro CORS ao carregar imagem:', image.url);
setImageError(true);
setImageLoading(false);
};
const handleImageLoad = () => {
console.log('Imagem carregada com sucesso:', image.url);
setImageLoading(false);
setImageError(false);
};
return (
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
<div className="relative aspect-square">
<img
src={image.url}
alt={image.prompt}
className="w-full h-full object-cover"
/>
{imageLoading && !imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-muted">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted text-muted-foreground p-4">
<Sparkles className="w-12 h-12 mb-2 opacity-50" />
<p className="text-sm text-center font-medium mb-1">Erro ao carregar</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">
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Abrir imagem em nova aba
</a>
<button
onClick={() => navigator.clipboard.writeText(image.url)}
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
>
Copiar URL
</button>
</div>
</div>
) : (
<img
src={image.url}
alt={image.prompt}
className="w-full h-full object-cover"
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
referrerPolicy="no-referrer"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
<div className="flex items-center justify-between text-xs text-white/70">
<span>{image.size}</span>
<span>{image.timestamp}</span>
<span>{IMAGE_SIZE_OPTIONS[image.size].label}</span>
<span>{getRelativeTime(image.timestamp)}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
className="flex-1 gap-1"
onClick={() => onDownload(image)}
>
<Download className="w-3 h-3" />
Baixar
+132
View File
@@ -0,0 +1,132 @@
/**
* Configuração de modelos de IA disponíveis
* Mapeia os nomes de exibição para os IDs usados pela API
*/
export interface ModelConfig {
name: string; // Nome exibido na interface
id: string; // ID enviado para a API
description?: string; // Descrição opcional do modelo
}
/**
* Lista de modelos disponíveis
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
* Futuramente será integrado com API para buscar dinamicamente.
*/
export const AVAILABLE_MODELS: ModelConfig[] = [
{
name: 'GPT-4.1',
id: '1',
description: 'Modelo GPT-4.1',
},
{
name: 'GPT-4o',
id: '2',
description: 'Modelo GPT-4o',
},
{
name: 'GPT-5 Mini',
id: '3',
description: 'Modelo GPT-5 Mini',
},
{
name: 'Gemini 2.0 Flash',
id: '4',
description: 'Google Gemini 2.0 Flash',
},
{
name: 'Claude Sonnet 4.5',
id: '5',
description: 'Anthropic Claude Sonnet 4.5',
},
{
name: 'DeepSeek V3.2 Chat',
id: '6',
description: 'DeepSeek V3.2 Chat',
},
{
name: 'DeepSeek V3.2 Reasoner',
id: '7',
description: 'DeepSeek V3.2 Reasoner',
},
{
name: 'Gemini 2.5 Flash',
id: '8',
description: 'Google Gemini 2.5 Flash',
},
{
name: 'Gemini 2.5 Flash-Lite',
id: '9',
description: 'Google Gemini 2.5 Flash-Lite',
},
{
name: 'Claude Haiku 4.5',
id: '10',
description: 'Anthropic Claude Haiku 4.5',
},
{
name: 'GPT-4o Mini',
id: '11',
description: 'Modelo GPT-4o Mini',
},
];
/**
* Mapeia o nome do modelo para o ID da API
*
* @param modelName - Nome do modelo exibido na interface
* @returns ID do modelo para a API, ou o próprio nome se não encontrado
*/
export function getModelId(modelName: string): string {
const model = AVAILABLE_MODELS.find(m => m.name === modelName);
return model?.id || modelName;
}
/**
* Mapeia o ID da API para o nome de exibição
*
* @param modelId - ID do modelo na API
* @returns Nome do modelo para exibir, ou o próprio ID se não encontrado
*/
export function getModelName(modelId: string): string {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
return model?.name || modelId;
}
/**
* Obtém a configuração completa de um modelo pelo nome
*
* @param modelName - Nome do modelo
* @returns Configuração do modelo ou undefined
*/
export function getModelConfig(modelName: string): ModelConfig | undefined {
return AVAILABLE_MODELS.find(m => m.name === modelName);
}
/**
* Retorna apenas os nomes dos modelos para uso em dropdowns
*/
export function getModelNames(): string[] {
return AVAILABLE_MODELS.map(m => m.name);
}
/**
* FUTURA INTEGRAÇÃO COM BANCO DE DADOS
*
* Quando a API estiver pronta, substituir AVAILABLE_MODELS por chamada à API:
*
* export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
* const response = await apiService.get('/api/models');
* return response.data.map((model: any) => ({
* name: model.name,
* id: model.id.toString(),
* description: model.description || '',
* }));
* }
*
* Nos componentes, usar:
* - useEffect para carregar modelos na montagem
* - useState para armazenar lista de modelos
* - Loading state durante o fetch
*/
+127
View File
@@ -0,0 +1,127 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* Configuração centralizada da API
* Todas as chamadas de API devem usar este serviço para garantir
* autenticação e configuração consistente
*/
class ApiService {
private axiosInstance: AxiosInstance;
private apiKey: string;
private baseURL: string;
constructor() {
// Busca configurações das variáveis de ambiente
this.apiKey = import.meta.env.VITE_API_KEY || '';
this.baseURL = import.meta.env.VITE_API_BASE_URL || '';
// Validação das variáveis de ambiente
if (!this.apiKey) {
console.error('VITE_API_KEY não configurada no arquivo .env');
}
if (!this.baseURL) {
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
}
// Cria instância do Axios com configurações padrão
this.axiosInstance = axios.create({
baseURL: this.baseURL,
timeout: 60000, // 60 segundos para upload de arquivos
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para adicionar API Key em todas as requisições
this.axiosInstance.interceptors.request.use(
(config) => {
// Adiciona a API Key no header de todas as requisições
if (this.apiKey) {
config.headers['apikey'] = this.apiKey;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de resposta para tratamento centralizado de erros
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// Log de erro para debug
console.error('API Error:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Retorna erro formatado
return Promise.reject({
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
status: error.response?.status,
data: error.response?.data,
});
}
);
}
/**
* Requisição GET
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(url, config);
}
/**
* Requisição POST
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, data, config);
}
/**
* Requisição POST com FormData (para upload de arquivos)
*/
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, formData, {
...config,
headers: {
...config?.headers,
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Requisição PUT
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.put<T>(url, data, config);
}
/**
* Requisição DELETE
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(url, config);
}
/**
* Retorna a URL base configurada
*/
getBaseURL(): string {
return this.baseURL;
}
/**
* Retorna a instância do Axios (uso avançado)
*/
getInstance(): AxiosInstance {
return this.axiosInstance;
}
}
// Exporta instância única (Singleton)
export const apiService = new ApiService();
+197
View File
@@ -0,0 +1,197 @@
import { apiService } from './api';
/**
* Tipos de vozes disponíveis para geração de áudio
*/
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
/**
* Interface para a resposta da API de geração de áudio
*/
export interface AudioGenerationResponse {
success: boolean;
audio_url: string; // URL do áudio gerado
audio_generation_id: string;
message: string; // Texto que foi convertido em áudio
}
/**
* Interface para os dados necessários para geração de áudio
*/
export interface AudioGenerationRequest {
message: string;
voice: VoiceType;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Informações sobre cada tipo de voz disponível
*/
export const VOICE_OPTIONS = {
alloy: {
label: "Alloy",
gender: "Masculina",
style: "Neutra, equilibrada, tom corporativo",
description: "Boa para tutoriais e comunicações institucionais."
},
echo: {
label: "Echo",
gender: "Masculina",
style: "Forte e profissional, mais grave",
description: "Ideal para voz de autoridade ou locução firme."
},
fable: {
label: "Fable",
gender: "Feminina",
style: "Narrativa, calorosa e envolvente",
description: "Ótima para storytelling e áudios empáticos."
},
onyx: {
label: "Onyx",
gender: "Masculina",
style: "Grave, autoritária, impactante",
description: "Excelente para trailers, mensagens sérias ou institucionais."
},
nova: {
label: "Nova",
gender: "Feminina",
style: "Brilhante, animada, energética",
description: "Boa para vídeos curtos, marketing ou conteúdos leves."
},
shimmer: {
label: "Shimmer",
gender: "Feminina",
style: "Suave, otimista, clara",
description: "Boa para mensagens acolhedoras, explicações e IA conversacional."
}
} as const;
/**
* Serviço de geração de áudio (Text-to-Speech)
*/
class AudioGenerationService {
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
/**
* Gera um arquivo de áudio a partir de texto
*
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
const { message, voice, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida o texto
if (!message || message.trim().length === 0) {
throw {
success: false,
message: 'O texto não pode estar vazio',
};
}
// Valida a voz
if (!this.isValidVoice(voice)) {
throw {
success: false,
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
};
}
// Log para debug (remover em produção se necessário)
console.log('Gerando áudio:', {
messageLength: message.length,
voice,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.post<AudioGenerationResponse>(
this.AUDIO_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId,
user_email: email,
message: message,
voice: voice,
}
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na geração de áudio:', error);
throw {
success: false,
message: error.message || 'Erro ao gerar áudio',
status: error.status,
};
}
}
/**
* Valida se a voz selecionada é suportada
*
* @param voice - Voz a ser validada
* @returns true se a voz é válida
*/
isValidVoice(voice: string): voice is VoiceType {
return Object.keys(VOICE_OPTIONS).includes(voice);
}
/**
* Obtém informações sobre uma voz específica
*
* @param voice - Tipo de voz
* @returns Informações da voz
*/
getVoiceInfo(voice: VoiceType) {
return VOICE_OPTIONS[voice];
}
/**
* Lista todas as vozes disponíveis
*
* @returns Array com todas as opções de voz
*/
getAllVoices() {
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
value: key as VoiceType,
...info,
}));
}
/**
* Valida o texto para geração de áudio
*
* @param text - Texto a ser validado
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
* @returns Objeto com resultado da validação
*/
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
if (!text || text.trim().length === 0) {
return {
valid: false,
error: 'O texto não pode estar vazio',
};
}
if (text.length > maxLength) {
return {
valid: false,
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true };
}
}
// Exporta instância única (Singleton)
export const audioGenerationService = new AudioGenerationService();
+481
View File
@@ -0,0 +1,481 @@
import { apiService } from './api';
/**
* Interface para a resposta da API de chat
*/
export interface ChatResponse {
success: boolean;
response: string; // Mensagem da IA
chat_id: string; // ID do chat retornado pela API (importante para manter contexto)
}
/**
* Interface para os dados necessários para enviar mensagem
*/
export interface ChatMessageRequest {
chatId: string;
message: string;
modelId: string;
personalidade?: string;
anexos?: File[]; // Array de até 5 arquivos
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Interface para armazenamento local de conversas
* Preparando para futura integração com banco de dados
*/
export interface StoredChat {
id: string;
title: string;
createdAt: Date;
updatedAt: Date;
model: string;
systemPrompt: string;
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
model?: string;
timestamp: Date;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}>;
}
/**
* Interface para armazenamento de pastas
* Preparando para futura integração com banco de dados
*/
export interface StoredFolder {
id: string;
name: string;
createdAt: Date;
chatIds: string[];
}
/**
* Serviço de chat com IA
*/
class ChatService {
private readonly CHAT_ENDPOINT = '/webhook/codex/message';
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
// Formatos de arquivo permitidos (atualmente)
private readonly ALLOWED_FILE_TYPES = {
// Formatos ativos
'application/pdf': { ext: '.pdf', label: 'PDF' },
'image/png': { ext: '.png', label: 'PNG' },
'image/jpeg': { ext: '.jpg, .jpeg', label: 'JPEG' },
'image/webp': { ext: '.webp', label: 'WebP' },
// Formatos futuros (desabilitados por enquanto)
// 'text/csv': { ext: '.csv', label: 'CSV' },
// 'text/plain': { ext: '.txt', label: 'TXT' },
// 'application/json': { ext: '.json', label: 'JSON' },
// 'application/vnd.ms-excel': { ext: '.xls', label: 'XLS' },
// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { ext: '.xlsx', label: 'XLSX' },
};
// Número máximo de anexos permitidos
private readonly MAX_ATTACHMENTS = 5;
/**
* Gera um chat_id único e seguro usando hash
* Formato: timestamp + random + hash
*
* NOTA: Esta função está mantida para compatibilidade e uso no localStorage,
* mas para comunicação com a API, o fluxo correto é:
* 1. Enviar chat_id: "0" na primeira mensagem
* 2. API retorna o chat_id real
* 3. Usar o chat_id retornado nas próximas mensagens
*
* @returns string - ID único para o chat (uso local)
*/
generateChatId(): string {
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 15);
const randomPart2 = Math.random().toString(36).substring(2, 15);
// Combina timestamp e partes aleatórias para criar ID único
const chatId = `chat_${timestamp}_${randomPart}${randomPart2}`;
return chatId;
}
/**
* Envia mensagem para a API de chat
*
* @param request - Dados da requisição (mensagem, modelo, anexos, etc)
* @returns Promise com a resposta da API
*/
async sendMessage(request: ChatMessageRequest): Promise<ChatResponse> {
const {
chatId,
message,
modelId,
personalidade,
anexos,
userEmail,
estabelecimentoId
} = request;
// Usa valores do .env se não forem fornecidos
const email = userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
// Validações
if (!chatId) {
throw {
success: false,
message: 'ID do chat é obrigatório',
};
}
if (!message || message.trim().length === 0) {
throw {
success: false,
message: 'A mensagem não pode estar vazia',
};
}
if (!modelId) {
throw {
success: false,
message: 'Modelo da IA é obrigatório',
};
}
// Valida número de anexos
if (anexos && anexos.length > 5) {
throw {
success: false,
message: 'Máximo de 5 arquivos anexos permitidos',
};
}
// Cria FormData para envio multipart
const formData = new FormData();
formData.append('estabelecimento_id', estabId.toString());
formData.append('chat_id', chatId);
formData.append('user_email', email);
formData.append('model_id', modelId);
formData.append('message', message);
// Adiciona personalidade se fornecida
if (personalidade && personalidade.trim().length > 0) {
formData.append('personalidade', personalidade);
}
// Adiciona anexos (máximo 5)
if (anexos && anexos.length > 0) {
anexos.forEach((file, index) => {
if (index < 5) {
formData.append(`anexo${index + 1}`, file);
}
});
}
// Log para debug (remover em produção se necessário)
console.log('Enviando mensagem:', {
chatId,
messageLength: message.length,
modelId,
hasPersonalidade: !!personalidade,
attachmentsCount: anexos?.length || 0,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.postFormData<ChatResponse>(
this.CHAT_ENDPOINT,
formData
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro ao enviar mensagem:', error);
throw {
success: false,
message: error.message || 'Erro ao comunicar com a IA',
status: error.status,
};
}
}
/**
* Salva ou atualiza um chat no localStorage
* Preparado para futura migração para banco de dados
*
* @param chat - Chat a ser salvo
*/
saveChat(chat: StoredChat): void {
try {
const chats = this.getAllChats();
const existingIndex = chats.findIndex(c => c.id === chat.id);
if (existingIndex >= 0) {
chats[existingIndex] = {
...chat,
updatedAt: new Date(),
};
} else {
chats.push(chat);
}
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(chats));
} catch (error) {
console.error('Erro ao salvar chat:', error);
throw new Error('Não foi possível salvar o chat');
}
}
/**
* Busca um chat específico por ID
*
* @param chatId - ID do chat
* @returns Chat encontrado ou undefined
*/
getChat(chatId: string): StoredChat | undefined {
const chats = this.getAllChats();
return chats.find(c => c.id === chatId);
}
/**
* Retorna todos os chats salvos
*
* @returns Array de chats
*/
getAllChats(): StoredChat[] {
try {
const chatsJson = localStorage.getItem(this.STORAGE_KEY_CHATS);
if (!chatsJson) return [];
const chats = JSON.parse(chatsJson);
// Converte strings de data para objetos Date
return chats.map((chat: any) => ({
...chat,
createdAt: new Date(chat.createdAt),
updatedAt: new Date(chat.updatedAt),
messages: chat.messages.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
}));
} catch (error) {
console.error('Erro ao carregar chats:', error);
return [];
}
}
/**
* Deleta um chat
*
* @param chatId - ID do chat a ser deletado
*/
deleteChat(chatId: string): void {
try {
const chats = this.getAllChats();
const filteredChats = chats.filter(c => c.id !== chatId);
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(filteredChats));
} catch (error) {
console.error('Erro ao deletar chat:', error);
throw new Error('Não foi possível deletar o chat');
}
}
/**
* Salva uma pasta no localStorage
*
* @param folder - Pasta a ser salva
*/
saveFolder(folder: StoredFolder): void {
try {
const folders = this.getAllFolders();
const existingIndex = folders.findIndex(f => f.id === folder.id);
if (existingIndex >= 0) {
folders[existingIndex] = folder;
} else {
folders.push(folder);
}
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(folders));
} catch (error) {
console.error('Erro ao salvar pasta:', error);
throw new Error('Não foi possível salvar a pasta');
}
}
/**
* Retorna todas as pastas salvas
*
* @returns Array de pastas
*/
getAllFolders(): StoredFolder[] {
try {
const foldersJson = localStorage.getItem(this.STORAGE_KEY_FOLDERS);
if (!foldersJson) return [];
const folders = JSON.parse(foldersJson);
// Converte strings de data para objetos Date
return folders.map((folder: any) => ({
...folder,
createdAt: new Date(folder.createdAt),
}));
} catch (error) {
console.error('Erro ao carregar pastas:', error);
return [];
}
}
/**
* Deleta uma pasta
*
* @param folderId - ID da pasta a ser deletada
*/
deleteFolder(folderId: string): void {
try {
const folders = this.getAllFolders();
const filteredFolders = folders.filter(f => f.id !== folderId);
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(filteredFolders));
} catch (error) {
console.error('Erro ao deletar pasta:', error);
throw new Error('Não foi possível deletar a pasta');
}
}
/**
* Gera título automático para o chat baseado na primeira mensagem
*
* @param message - Primeira mensagem do usuário
* @param maxLength - Comprimento máximo do título
* @returns Título gerado
*/
generateChatTitle(message: string, maxLength: number = 50): string {
if (!message || message.trim().length === 0) {
return 'Nova Conversa';
}
const trimmed = message.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength) + '...';
}
/**
* Limpa todas as conversas (usar com cuidado)
*/
clearAllChats(): void {
localStorage.removeItem(this.STORAGE_KEY_CHATS);
}
/**
* Limpa todas as pastas (usar com cuidado)
*/
clearAllFolders(): void {
localStorage.removeItem(this.STORAGE_KEY_FOLDERS);
}
/**
* Valida anexos antes de enviar para a API
*
* @param files - Array de arquivos a serem validados
* @returns Objeto com resultado da validação
*/
validateAttachments(files: File[]): { valid: boolean; error?: string } {
// Valida número de anexos
if (files.length > this.MAX_ATTACHMENTS) {
return {
valid: false,
error: `Máximo de ${this.MAX_ATTACHMENTS} anexos permitidos. Você selecionou ${files.length}.`,
};
}
// Valida cada arquivo
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileType = file.type;
const fileName = file.name;
// Verifica se o tipo MIME é permitido
if (!this.ALLOWED_FILE_TYPES[fileType as keyof typeof this.ALLOWED_FILE_TYPES]) {
// Tenta validar pela extensão também
const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
const isExtensionValid = Object.values(this.ALLOWED_FILE_TYPES).some(
type => type.ext.includes(extension)
);
if (!isExtensionValid) {
const allowedFormats = Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.label)
.join(', ');
return {
valid: false,
error: `Arquivo "${fileName}" não é permitido. Formatos aceitos: ${allowedFormats}`,
};
}
}
}
return { valid: true };
}
/**
* Retorna lista de formatos de arquivo permitidos
*
* @returns Array com informações dos formatos
*/
getAllowedFileTypes() {
return Object.entries(this.ALLOWED_FILE_TYPES).map(([mimeType, info]) => ({
mimeType,
...info,
}));
}
/**
* Retorna string formatada com formatos permitidos (para exibição)
*
* @returns String formatada (ex: "PDF, PNG, JPEG, WebP")
*/
getAllowedFileTypesLabel(): string {
return Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.label)
.join(', ');
}
/**
* Retorna string com extensões para usar no input file accept
*
* @returns String formatada (ex: ".pdf,.png,.jpg,.jpeg,.webp")
*/
getAllowedFileExtensions(): string {
return Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.ext)
.join(',');
}
/**
* Retorna número máximo de anexos permitidos
*/
getMaxAttachments(): number {
return this.MAX_ATTACHMENTS;
}
}
// Exporta instância única (Singleton)
export const chatService = new ChatService();
+218
View File
@@ -0,0 +1,218 @@
import { apiService } from './api';
/**
* Tamanhos de imagem disponíveis
*/
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
/**
* Interface para a resposta da API de geração de imagens
*/
export interface ImageGenerationResponse {
success: boolean;
image_url: string; // URL da imagem gerada
image_generation_id: string;
message: string; // Descrição original
}
/**
* Interface para os dados necessários para geração de imagem
*/
export interface ImageGenerationRequest {
description: string;
size: ImageSize;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Informações sobre cada tamanho de imagem disponível
*/
export const IMAGE_SIZE_OPTIONS = {
'1024x1024': {
label: 'Quadrado',
dimensions: '1024x1024',
aspectRatio: '1:1',
description: 'Ideal para avatares, ícones e posts em redes sociais',
},
'1024x1792': {
label: 'Retrato',
dimensions: '1024x1792',
aspectRatio: '9:16',
description: 'Perfeito para stories, wallpapers verticais e reels',
},
'1792x1024': {
label: 'Paisagem',
dimensions: '1792x1024',
aspectRatio: '16:9',
description: 'Ótimo para banners, capas e thumbnails de vídeos',
},
} as const;
/**
* Serviço de geração de imagens com IA
*/
class ImageGenerationService {
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
/**
* Gera uma imagem a partir de uma descrição em texto
*
* @param request - Dados da requisição (descrição, tamanho, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
const { description, size, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida a descrição
if (!description || description.trim().length === 0) {
throw {
success: false,
message: 'A descrição não pode estar vazia',
};
}
// Valida o tamanho
if (!this.isValidSize(size)) {
throw {
success: false,
message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}`,
};
}
// Log para debug (remover em produção se necessário)
console.log('Gerando imagem:', {
descriptionLength: description.length,
size,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
// Nota: O campo no body é "estabelecito_id" (com typo na API)
const response = await apiService.post<ImageGenerationResponse>(
this.IMAGE_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId, // Mantém o typo da API original
user_email: email,
description: description,
size: size,
}
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na geração de imagem:', error);
throw {
success: false,
message: error.message || 'Erro ao gerar imagem',
status: error.status,
};
}
}
/**
* Valida se o tamanho selecionado é suportado
*
* @param size - Tamanho a ser validado
* @returns true se o tamanho é válido
*/
isValidSize(size: string): size is ImageSize {
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
}
/**
* Obtém informações sobre um tamanho específico
*
* @param size - Tamanho da imagem
* @returns Informações do tamanho
*/
getSizeInfo(size: ImageSize) {
return IMAGE_SIZE_OPTIONS[size];
}
/**
* Lista todos os tamanhos disponíveis
*
* @returns Array com todas as opções de tamanho
*/
getAllSizes() {
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
value: key as ImageSize,
...info,
}));
}
/**
* Valida a descrição para geração de imagem
*
* @param description - Descrição a ser validada
* @param minLength - Comprimento mínimo (padrão: 3 caracteres)
* @param maxLength - Comprimento máximo (padrão: 1000 caracteres)
* @returns Objeto com resultado da validação
*/
validateDescription(
description: string,
minLength: number = 3,
maxLength: number = 1000
): { valid: boolean; error?: string } {
if (!description || description.trim().length === 0) {
return {
valid: false,
error: 'A descrição não pode estar vazia',
};
}
if (description.trim().length < minLength) {
return {
valid: false,
error: `A descrição deve ter pelo menos ${minLength} caracteres`,
};
}
if (description.length > maxLength) {
return {
valid: false,
error: `A descrição é muito longa. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true };
}
/**
* Faz download de uma imagem gerada
*
* @param imageUrl - URL da imagem
* @param filename - Nome do arquivo (opcional)
*/
async downloadImage(imageUrl: string, filename?: string): Promise<void> {
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `imagem_${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Erro ao baixar imagem:', error);
throw new Error('Não foi possível baixar a imagem');
}
}
}
// Exporta instância única (Singleton)
export const imageGenerationService = new ImageGenerationService();
+12
View File
@@ -0,0 +1,12 @@
/**
* Exporta todos os serviços de API
*/
export { apiService } from './api';
export { transcriptionService } from './transcription';
export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration';
export { imageGenerationService, IMAGE_SIZE_OPTIONS } from './imageGeneration';
export type { TranscriptionResponse, TranscriptionRequest } from './transcription';
export type { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration';
export type { ImageGenerationResponse, ImageGenerationRequest, ImageSize } from './imageGeneration';
export type { ApiResponse, UserConfig, ApiError } from './types';
+110
View File
@@ -0,0 +1,110 @@
import { apiService } from './api';
/**
* Interface para a resposta da API de transcrição
*/
export interface TranscriptionResponse {
success: boolean;
transcription_id: string;
audio_url: string;
message: string;
}
/**
* Interface para os dados necessários para transcrição
*/
export interface TranscriptionRequest {
audioFile: File;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Serviço de transcrição de áudio
*/
class TranscriptionService {
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
/**
* Transcreve um arquivo de áudio
*
* @param request - Dados da requisição (arquivo, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> {
const { audioFile, userEmail, estabelecimentoId } = request;
// Cria FormData para envio multipart
const formData = new FormData();
formData.append('data', audioFile);
// Usa valores do .env se não forem fornecidos
const email = userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
formData.append('user_email', email);
formData.append('estabelecimento_id', estabId.toString());
// Log para debug (remover em produção se necessário)
console.log('Enviando transcrição:', {
fileName: audioFile.name,
fileSize: audioFile.size,
fileType: audioFile.type,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.postFormData<TranscriptionResponse>(
this.TRANSCRIPTION_ENDPOINT,
formData
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na transcrição:', error);
throw {
success: false,
message: error.message || 'Erro ao transcrever áudio',
status: error.status,
};
}
}
/**
* Valida se o arquivo de áudio é suportado
*
* @param file - Arquivo a ser validado
* @param maxSizeMB - Tamanho máximo em MB (padrão: 25MB)
* @returns Objeto com resultado da validação
*/
validateAudioFile(file: File, maxSizeMB: number = 25): { valid: boolean; error?: string } {
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = maxSizeMB * 1024 * 1024;
// Valida tamanho
if (file.size > MAX_FILE_SIZE) {
return {
valid: false,
error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`,
};
}
// Valida formato
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
return {
valid: false,
error: `Formato não suportado. Formatos aceitos: ${SUPPORTED_FORMATS.join(', ')}`,
};
}
return { valid: true };
}
}
// Exporta instância única (Singleton)
export const transcriptionService = new TranscriptionService();
+30
View File
@@ -0,0 +1,30 @@
/**
* Tipos compartilhados para as APIs
*/
/**
* Resposta padrão de sucesso/erro da API
*/
export interface ApiResponse<T = any> {
success: boolean;
message?: string;
data?: T;
}
/**
* Configuração de usuário para requisições
*/
export interface UserConfig {
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Resposta de erro da API
*/
export interface ApiError {
success: false;
message: string;
status?: number;
data?: any;
}
+11
View File
@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_API_KEY: string
readonly VITE_USER_EMAIL: string
readonly VITE_ESTABELECIMENTO_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}