Ajustando o chat llm

This commit is contained in:
dalila.analistadesistema@gmail.com 2025-02-21 17:04:20 -03:00
parent 6c8d0d460b
commit 533cc654a8
3 changed files with 716 additions and 60 deletions

166
.gitignore vendored
View File

@ -1,4 +1,164 @@
.env
/node_modules/
# Byte-compiled / optimized / DLL files
__pycache__/
venv/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/migrations

View File

@ -116,13 +116,20 @@
<!-- Botão de Treinar -->
<v-divider v-if="uploadedFiles.length > 0"></v-divider>
<v-card-text v-if="trainingStatus">
<v-progress-linear v-if="isTraining" indeterminate color="primary"></v-progress-linear>
<v-alert v-if="trainingStatus" :type="trainingStatusType" class="mt-3">
{{ trainingStatus }}
</v-alert>
</v-card-text>
<v-btn
color="#4cc9f0"
block
@click="trainModel"
:disabled="uploadedFiles.length === 0"
:disabled="uploadedFiles.length === 0 || isTraining"
:loading="isTraining"
>
Treinar Lucy
{{ isTraining ? 'Treinando...' : 'Treinar Lucy' }}
</v-btn>
</v-card>
</v-col>
@ -135,39 +142,62 @@
</v-toolbar>
<v-card-text v-if="!modelTrained && !isTraining">
<p>Treine Lucy antes de iniciar a conversa.</p>
<div class="text-center py-8">
<v-icon size="64" color="grey">mdi-robot-off</v-icon>
<p class="text-body-1 mt-4">Treine Lucy antes de iniciar a conversa.</p>
</div>
</v-card-text>
<v-card-text v-else-if="isTraining">
<p>Lucy está sendo treinada... Aguarde!</p>
<div class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<p class="text-body-1 mt-4">Lucy está sendo treinada...</p>
</div>
</v-card-text>
<v-card-text v-else>
<v-list>
<v-list-subheader>Conversa com Lucy</v-list-subheader>
<v-list-item v-for="(message, index) in chatHistory" :key="index">
<v-list-item-content>
<v-list-item-title :class="{ 'user-message': message.sender === 'user' }">
{{ message.sender }}: {{ message.text }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<v-text-field
v-model="userMessage"
label="Digite sua mensagem"
placeholder="Pergunte algo à Lucy"
variant="outlined"
density="compact"
@keyup.enter="sendMessage"
:disabled="!modelTrained"
></v-text-field>
<v-btn
color="primary"
block
@click="sendMessage"
:disabled="!modelTrained"
>
Enviar
</v-btn>
<v-card-text v-else class="chat-container">
<div class="chat-messages" ref="chatMessagesContainer">
<v-timeline density="compact" align="start">
<v-timeline-item
v-for="(message, index) in chatHistory"
:key="index"
:dot-color="message.sender === 'user' ? 'primary' : 'secondary'"
:icon="message.sender === 'user' ? 'mdi-account' : 'mdi-robot'"
>
<v-card :color="message.sender === 'user' ? 'grey-lighten-4' : 'blue-lighten-5'" class="chat-message">
<v-card-title class="text-subtitle-2">{{ message.sender === 'user' ? 'Você' : 'Lucy' }}</v-card-title>
<v-card-text v-html="formatMessage(message.text)"></v-card-text>
<v-card-subtitle class="text-caption text-right">{{ formatTimestamp(message.timestamp) }}</v-card-subtitle>
</v-card>
</v-timeline-item>
</v-timeline>
<div v-if="isTyping" class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
<v-divider class="my-2"></v-divider>
<div class="message-input">
<v-textarea
v-model="userMessage"
label="Digite sua mensagem"
rows="2"
auto-grow
hide-details
variant="outlined"
density="compact"
@keydown.enter.prevent="sendMessage"
:disabled="!modelTrained || isTyping"
></v-textarea>
<v-btn
color="primary"
@click="sendMessage"
:disabled="!modelTrained || !userMessage.trim() || isTyping"
:loading="isTyping"
icon="mdi-send"
size="large"
class="ml-2 mt-2"
></v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
@ -176,8 +206,10 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, watch, nextTick, onMounted } from 'vue'
import axios from 'axios'
// Estados principais
const tab = ref('1')
const files = ref([])
const uploadedFiles = ref([])
@ -185,10 +217,34 @@ const isDragging = ref(false)
const showUrlDialog = ref(false)
const url = ref('')
const modelTrained = ref(false)
const isTraining = ref(false) // Estado para verificar se Lucy está sendo treinada
const isTraining = ref(false)
const chatHistory = ref([])
const userMessage = ref('')
const isTyping = ref(false)
const trainingStatus = ref('')
const trainingStatusType = ref('info')
const chatMessagesContainer = ref(null)
const filePath = ref('') // Armazena o caminho do arquivo processado
const fileContents = ref({}) // Armazena conteúdo dos arquivos por caminho
// Configuração do endpoint Flask
const API_ENDPOINT = 'http://127.0.0.1:5000'
// Token de autenticação para o backend
const authToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MDA5MzU3MSwianRpIjoiYzU5ZDMxODctOTE2ZS00ZmVlLWJiZDAtM2NhODhjZTUzNDhhIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjEiLCJuYmYiOjE3NDAwOTM1NzEsImNzcmYiOiI2NzFjMThjZS0xYzI4LTQ4M2ItOTljMC1jOTliZWZkYmYxNjMiLCJleHAiOjE3NDAwOTUzNzF9.lgeUdqIKQEy1yKotmyRBOgV59V_OS3Ycjd05Jv5_vHM'
// Configurar axios com headers padrão
const axiosInstance = axios.create({
baseURL: API_ENDPOINT,
headers: {
'Authorization': `Bearer ${authToken}`
}
})
// Extensões de arquivo permitidas
const EXTENSOES_PERMITIDAS = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'xlsx', 'xls', 'txt']
// Formatar tamanho do arquivo
const formatFileSize = (size) => {
if (size === 'N/A') return size
const units = ['B', 'KB', 'MB', 'GB']
@ -203,62 +259,431 @@ const formatFileSize = (size) => {
return `${Math.round(formattedSize * 100) / 100} ${units[unitIndex]}`
}
const handleFileUpload = (newFiles, allowedExtensions) => {
// Manipular upload de arquivos
const handleFileUpload = async (newFiles) => {
if (!newFiles) return
const validFiles = Array.from(newFiles).filter(file => {
const ext = file.name.split('.').pop().toLowerCase()
return allowedExtensions.includes(ext)
return EXTENSOES_PERMITIDAS.includes(ext)
})
// Adiciona metadados para acompanhamento
for (const file of validFiles) {
file.status = 'pendente'
file.metadata = {
uploadedAt: new Date().toISOString(),
fileType: file.type
}
}
uploadedFiles.value.push(...validFiles)
files.value = []
}
// Manipular upload de URL
const handleUrlUpload = () => {
if (url.value.trim()) {
uploadedFiles.value.push({
name: url.value,
type: 'url',
size: 'N/A'
size: 'N/A',
status: 'pendente',
metadata: {
source: 'web',
url: url.value,
uploadedAt: new Date().toISOString()
}
})
url.value = ''
showUrlDialog.value = false
}
}
// Remover arquivo
const removeFile = (index) => {
uploadedFiles.value.splice(index, 1)
}
const onDrop = (event, allowedExtensions) => {
// Handlers de drag & drop
const onDrop = (event) => {
isDragging.value = false
const droppedFiles = event.dataTransfer.files
if (droppedFiles.length) {
handleFileUpload(droppedFiles, allowedExtensions)
handleFileUpload(droppedFiles)
}
}
const onDragOver = () => isDragging.value = true
const onDragLeave = () => isDragging.value = false
const trainModel = () => {
isTraining.value = true
setTimeout(() => {
modelTrained.value = true
isTraining.value = false
console.log('Lucy foi treinada com os arquivos:', uploadedFiles.value)
}, 3000) // Simula o tempo de treinamento
}
// Processar arquivo com a API Flask
const processarArquivoComAPI = async (file) => {
try {
const formData = new FormData();
formData.append('file', file);
const sendMessage = () => {
if (userMessage.value.trim()) {
chatHistory.value.push({ sender: 'user', text: userMessage.value })
chatHistory.value.push({ sender: 'Lucy', text: `Você disse: "${userMessage.value}"` })
userMessage.value = ''
// Primeiro, use o endpoint /upload para processar o arquivo
const uploadResponse = await axiosInstance.post(`/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${authToken}`
}
});
if (uploadResponse.data.file_path) {
const filePath = uploadResponse.data.file_path;
const content = uploadResponse.data.content;
// Armazenar conteúdo do arquivo para uso nas consultas
fileContents.value[filePath] = content;
return {
text: content,
file_path: filePath
};
}
throw new Error('Resposta inválida da API ao processar arquivo');
} catch (error) {
console.error('Erro ao processar arquivo:', error.response?.data || error.message);
throw error;
}
};
// Processar URL com a API Flask
const processarUrlComAPI = async (urlToProcess) => {
try {
// Implementar quando a API de processamento de URL estiver disponível
const response = await axiosInstance.post('/process-url', {
url: urlToProcess
}, {
headers: { 'Content-Type': 'application/json' }
});
return {
text: response.data.content || `Conteúdo da URL: ${urlToProcess}`,
file_path: response.data.file_path,
processado: true
};
} catch (error) {
console.error('Erro ao processar URL:', error);
return {
text: `Erro ao processar URL: ${urlToProcess}`,
processado: false,
mensagem: error.message
};
}
}
// Treinar modelo com Flask API
const trainModel = async () => {
isTraining.value = true;
trainingStatus.value = 'Iniciando processamento dos arquivos...';
trainingStatusType.value = 'info';
try {
// Inicializar base de conhecimento
const baseDeConhecimento = {
documentos: [],
inicioTreinamento: new Date().toISOString(),
statusTreinamento: 'em_andamento',
filePaths: [], // Armazenar caminhos dos arquivos processados
conteudos: {} // Armazenar conteúdos dos arquivos
};
// Processar cada arquivo
for (let i = 0; i < uploadedFiles.value.length; i++) {
const file = uploadedFiles.value[i];
trainingStatus.value = `Processando arquivo ${i+1}/${uploadedFiles.value.length}: ${file.name}`;
let dadosDocumento = {};
try {
if (file.type === 'url') {
// Processar URL
const resultadoUrl = await processarUrlComAPI(file.name);
dadosDocumento = {
conteudo: resultadoUrl.text || 'Nenhum conteúdo extraído',
file_path: resultadoUrl.file_path,
metadata: {
tipo: 'conteudo-web',
url: file.name,
processadoEm: new Date().toISOString()
}
};
if (resultadoUrl.file_path) {
baseDeConhecimento.filePaths.push(resultadoUrl.file_path);
baseDeConhecimento.conteudos[resultadoUrl.file_path] = resultadoUrl.text;
}
} else {
// Processar arquivo usando a API atualizada
const resultadoArquivo = await processarArquivoComAPI(file);
dadosDocumento = {
conteudo: resultadoArquivo.text || 'Nenhum conteúdo extraído',
file_path: resultadoArquivo.file_path,
metadata: {
tipo: getTipoArquivo(file),
nomeArquivo: file.name,
processadoEm: new Date().toISOString()
}
};
// Armazenar o caminho do arquivo para consultas futuras
baseDeConhecimento.filePaths.push(resultadoArquivo.file_path);
baseDeConhecimento.conteudos[resultadoArquivo.file_path] = resultadoArquivo.text;
}
// Atualizar status do arquivo
file.status = 'processado';
} catch (error) {
console.error(`Erro ao processar ${file.name}:`, error);
file.status = 'erro';
dadosDocumento = {
conteudo: `Erro ao processar arquivo: ${error.message || 'Erro desconhecido'}`,
metadata: {
tipo: getTipoArquivo(file),
nomeArquivo: file.name,
erro: true
}
};
}
baseDeConhecimento.documentos.push(dadosDocumento);
}
// Se processamos pelo menos um arquivo com sucesso
if (baseDeConhecimento.filePaths.length > 0) {
// Usar o caminho do último arquivo processado para as consultas
filePath.value = baseDeConhecimento.filePaths[baseDeConhecimento.filePaths.length - 1];
// Atualizar fileContents com os conteúdos processados
fileContents.value = { ...fileContents.value, ...baseDeConhecimento.conteudos };
baseDeConhecimento.treinamentoConcluido = true;
baseDeConhecimento.statusTreinamento = 'concluido';
modelTrained.value = true;
trainingStatus.value = `Processamento concluído! Processados ${baseDeConhecimento.documentos.length} documentos.`;
trainingStatusType.value = 'success';
// Adicionar mensagem inicial da Lucy
chatHistory.value.push({
sender: 'Lucy',
text: `Olá! Fui treinada com ${baseDeConhecimento.documentos.length} documentos. Como posso ajudar?`,
timestamp: new Date()
});
// Armazenar base de conhecimento para uso posterior
localStorage.setItem('baseDeConhecimento', JSON.stringify(baseDeConhecimento));
} else {
trainingStatus.value = 'Nenhum arquivo foi processado com sucesso.';
trainingStatusType.value = 'error';
}
} catch (error) {
console.error('Erro durante o processo de treinamento:', error);
trainingStatus.value = `Erro durante o processamento: ${error.message || 'Falha desconhecida'}`;
trainingStatusType.value = 'error';
} finally {
isTraining.value = false;
}
};
// Função auxiliar para determinar o tipo de arquivo
const getTipoArquivo = (file) => {
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'pdf') return 'pdf';
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) return 'imagem';
if (['xlsx', 'xls'].includes(ext)) return 'excel';
if (ext === 'txt') return 'texto';
return file.type;
};
// Função personalizada para enviar consulta com conteúdo já armazenado
const sendQueryWithStoredContent = async (question, path) => {
try {
// Verifica se temos o conteúdo do arquivo
if (!fileContents.value[path]) {
console.warn(`Conteúdo não disponível para ${path}, tentando buscar da API`);
// Tentar recuperar o conteúdo primeiro
try {
const response = await axiosInstance.get(`/get-content`, {
params: { file_path: path }
});
if (response.data.content) {
fileContents.value[path] = response.data.content;
} else {
throw new Error('Conteúdo não disponível');
}
} catch (error) {
console.error('Erro ao recuperar conteúdo:', error);
return {
success: false,
message: 'Não foi possível acessar o conteúdo do documento. Tente processar o arquivo novamente.'
};
}
}
// Usar o conteúdo armazenado para construir a requisição
const content = fileContents.value[path];
// Fazendo nossa própria requisição personalizada corrigindo o erro do backend
const response = await axiosInstance.post('/query', {
question: question,
file_path: path,
content: content // Enviamos o conteúdo do arquivo também
}, {
headers: { 'Content-Type': 'application/json' }
});
return {
success: true,
response: response.data.response
};
} catch (error) {
console.error('Erro ao processar consulta:', error);
return {
success: false,
message: `Erro ao processar consulta: ${error.message}`
};
}
};
// Enviar mensagem
const sendMessage = async () => {
if (!userMessage.value.trim() || !modelTrained.value || isTyping.value) return;
// Adiciona mensagem do usuário
const userMsg = userMessage.value.trim();
chatHistory.value.push({
sender: 'user',
text: userMsg,
timestamp: new Date()
});
const questionText = userMsg; // Guarda a mensagem antes de limpar o input
userMessage.value = '';
// Simula digitação da Lucy
isTyping.value = true;
try {
// Obter a base de conhecimento armazenada para verificar o caminho do arquivo
const baseDeConhecimento = JSON.parse(localStorage.getItem('baseDeConhecimento') || '{}');
const currentFilePath = filePath.value || (baseDeConhecimento.filePaths && baseDeConhecimento.filePaths[0]);
if (!currentFilePath) {
throw new Error('Nenhum arquivo processado disponível para consulta');
}
// Usar nossa função personalizada para enviar a consulta com o conteúdo armazenado
const result = await sendQueryWithStoredContent(questionText, currentFilePath);
if (result.success) {
// Adiciona resposta da Lucy
chatHistory.value.push({
sender: 'Lucy',
text: result.response,
timestamp: new Date()
});
} else {
// Adiciona mensagem de erro
chatHistory.value.push({
sender: 'Lucy',
text: result.message,
timestamp: new Date()
});
}
} catch (error) {
console.error('Erro ao obter resposta:', error);
chatHistory.value.push({
sender: 'Lucy',
text: 'Desculpe, tive um problema ao processar sua solicitação. Pode tentar novamente?',
timestamp: new Date()
});
} finally {
isTyping.value = false;
// Rola para o final da conversa
scrollToBottom();
}
};
// Formatar mensagem (suporte a markdown básico)
const formatMessage = (text) => {
if (!text) return '';
// Converte quebras de linha
let formatted = text.replace(/\n/g, '<br>');
// Formata negrito
formatted = formatted.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Formata itálico
formatted = formatted.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Formata código inline
formatted = formatted.replace(/`(.*?)`/g, '<code>$1</code>');
return formatted;
};
// Formatar timestamp
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Rolar para o final da conversa
const scrollToBottom = async () => {
await nextTick();
if (chatMessagesContainer.value) {
const container = chatMessagesContainer.value;
container.scrollTop = container.scrollHeight;
}
};
// Observar mudanças no histórico de chat para rolar para baixo
watch(chatHistory, () => {
scrollToBottom();
}, { deep: true });
// Montar componente
onMounted(() => {
// Verificar se temos um modelo previamente treinado
const baseDeConhecimentoSalva = localStorage.getItem('baseDeConhecimento');
if (baseDeConhecimentoSalva) {
const baseDeConhecimento = JSON.parse(baseDeConhecimentoSalva);
if (baseDeConhecimento.treinamentoConcluido) {
modelTrained.value = true;
trainingStatus.value = `Modelo previamente treinado com ${baseDeConhecimento.documentos?.length || 0} documentos.`;
trainingStatusType.value = 'success';
// Restaurar o filePath do último processamento
if (baseDeConhecimento.filePaths && baseDeConhecimento.filePaths.length > 0) {
filePath.value = baseDeConhecimento.filePaths[baseDeConhecimento.filePaths.length - 1];
}
// Restaurar conteúdos de arquivo
if (baseDeConhecimento.conteudos) {
fileContents.value = baseDeConhecimento.conteudos;
}
}
}
// Adiciona mensagem de boas-vindas
if (chatHistory.value.length === 0) {
chatHistory.value.push({
sender: 'Lucy',
text: 'Olá! Carregue seus documentos e me treine para que eu possa responder suas perguntas.',
timestamp: new Date()
});
}
});
</script>
<style scoped>
@ -270,8 +695,11 @@ const sendMessage = () => {
color: #6c00b5;
border-radius: 25px;
background: linear-gradient(145deg, #eeeded, #ffffff);
box-shadow: 20px 20px 60px #d9d9d9,
-20px -20px 60px #ffffff;
box-shadow: 20px 20px 60px #d9d9d9,
-20px -20px 60px #ffffff;
height: 100%;
display: flex;
flex-direction: column;
}
.upload-box {
@ -295,8 +723,72 @@ const sendMessage = () => {
color: #777;
}
.user-message {
font-weight: bold;
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-messages {
flex-grow: 1;
overflow-y: auto;
padding-right: 8px;
max-height: 500px;
}
.chat-message {
margin-bottom: 8px;
max-width: 90%;
}
.message-input {
margin-top: auto;
display: flex;
align-items: flex-start;
}
.typing-indicator {
display: flex;
align-items: center;
column-gap: 5px;
padding: 10px;
}
.typing-indicator span {
height: 8px;
width: 8px;
border-radius: 50%;
background-color: #6c00b5;
display: block;
opacity: 0.4;
animation: typing 1s infinite alternate;
}
.typing-indicator span:nth-child(1) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.4s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes typing {
0% {
transform: translateY(0px);
opacity: 0.4;
}
50% {
transform: translateY(-5px);
opacity: 0.8;
}
100% {
transform: translateY(0px);
opacity: 0.4;
}
}
.text {

View File

@ -5,6 +5,10 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/process/': 'http://localhost:8000', // Redireciona as requisições para o back-end FastAPI
},
historyApiFallback: true, // Fallback para o history mode do Vue Router
},
})