Primeiro Commit
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user