diff --git a/.github/workflows/aws-cd.yml b/.github/workflows/aws-cd.yml index f03aeb4..a2206cc 100644 --- a/.github/workflows/aws-cd.yml +++ b/.github/workflows/aws-cd.yml @@ -40,6 +40,8 @@ jobs: npm run build env: VITE_STAGE: ${{ github.ref_name }} + VITE_API_URL: ${{vars.API_URL}} + VITE_BUCKET_NAME: ${{vars.BUCKET_NAME}} - name: Setup envs run: | echo AWS_REGION=${{ vars.AWS_REGION }} >> $GITHUB_ENV diff --git a/index.html b/index.html index a4a211e..b5d4932 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/package-lock.json b/package-lock.json index 5899ab2..b76baa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.5.3", "tailwindcss": "^4.1.5", + "uuidv4": "^6.2.13", "zod": "^3.25.28" }, "devDependencies": { @@ -3265,6 +3266,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.32.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", @@ -7952,6 +7959,26 @@ } } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuidv4": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/uuidv4/-/uuidv4-6.2.13.tgz", + "integrity": "sha512-AXyzMjazYB3ovL3q051VLH06Ixj//Knx7QnUSi1T//Ie3io6CpsPu9nVMOx5MoLWh6xV0B9J0hIaxungxXUbPQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@types/uuid": "8.3.4", + "uuid": "8.3.2" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index 1c58444..e4cfbd2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.5.3", "tailwindcss": "^4.1.5", + "uuidv4": "^6.2.13", "zod": "^3.25.28" }, "devDependencies": { diff --git a/src/assets/berti.jfif b/src/assets/berti.jfif deleted file mode 100644 index 9d4012e..0000000 Binary files a/src/assets/berti.jfif and /dev/null differ diff --git a/src/assets/izabel.jfif b/src/assets/izabel.jfif deleted file mode 100644 index d4307c1..0000000 Binary files a/src/assets/izabel.jfif and /dev/null differ diff --git a/src/assets/julia.jfif b/src/assets/julia.jfif deleted file mode 100644 index f43acb3..0000000 Binary files a/src/assets/julia.jfif and /dev/null differ diff --git a/src/assets/rubio.jfif b/src/assets/rubio.jfif deleted file mode 100644 index effc2fa..0000000 Binary files a/src/assets/rubio.jfif and /dev/null differ diff --git a/src/assets/sakamoto.jfif b/src/assets/sakamoto.jfif deleted file mode 100644 index 696105e..0000000 Binary files a/src/assets/sakamoto.jfif and /dev/null differ diff --git a/src/domain/knowledge-base.ts b/src/domain/knowledge-base.ts index 906bf8f..793a789 100644 --- a/src/domain/knowledge-base.ts +++ b/src/domain/knowledge-base.ts @@ -10,6 +10,7 @@ export type KnowledgeBase = { status: STATUS files: KnowledgeBaseFile[] totalSizeMB: number + keys: KnowledgeBaseKey[] } export type KnowledgeBaseFile = { @@ -17,3 +18,8 @@ export type KnowledgeBaseFile = { sizeMB: number url: string } + +export type KnowledgeBaseKey = { + kbKey: string + kbKeyAlias: string +} diff --git a/src/features/docs/pages/about-project.tsx b/src/features/docs/pages/about-project.tsx new file mode 100644 index 0000000..6e49858 --- /dev/null +++ b/src/features/docs/pages/about-project.tsx @@ -0,0 +1,57 @@ +import { motion } from 'framer-motion' +import { h1, h2, p } from '@/shared/components/typography' + +export function AboutProjectPage() { + return ( + + {h1({ children: 'Sobre o Projeto' })} + + {h2({ children: 'Por que criamos o Knowly?' })} + {p({ + children: ( + <> + A inteligência artificial está mudando a forma como empresas + trabalham, trazendo mais agilidade e eficiência para o dia a dia. + Mas, para a maioria dos negócios, usar IA ainda é caro e complicado: + exige conhecimento técnico, tempo e investimento alto em tecnologia. + + ) + })} + {p({ + children: ( + <> + O Knowly nasceu para simplificar esse cenário. Queremos que qualquer + empresa consiga aproveitar o poder dos grandes modelos de linguagem + para responder dúvidas, organizar informações e automatizar tarefas, + sem precisar se preocupar com detalhes técnicos ou infraestrutura. + + ) + })} + {p({ + children: ( + <> + Nossa plataforma permite que você envie seus próprios documentos e, + em poucos cliques, já tenha uma base de conhecimento inteligente + pronta para ser usada no seu atendimento, site ou operação interna. + Tudo isso com segurança, praticidade e custos acessíveis. + + ) + })} + {p({ + children: ( + <> + O objetivo é ajudar empresas a inovar, ganhar tempo e focar no que + realmente importa: crescer e atender melhor seus clientes, usando IA + de verdade no dia a dia. + + ) + })} + + ) +} + +export default AboutProjectPage diff --git a/src/features/docs/pages/foundation-models.tsx b/src/features/docs/pages/foundation-models.tsx new file mode 100644 index 0000000..407c874 --- /dev/null +++ b/src/features/docs/pages/foundation-models.tsx @@ -0,0 +1,129 @@ +import { motion } from 'framer-motion' +import { h1, h2, p, ul, li } from '@/shared/components/typography' + +export function FoundationModelsPage() { + return ( + + {h1({ children: 'Modelos de Fundação AWS' })} + + {h2({ children: 'O que são modelos de fundação?' })} + {p({ + children: ( + <> + Modelos de fundação são inteligências artificiais treinadas para + entender e gerar textos, responder perguntas, resumir informações e + até criar conteúdos. Eles servem como base para várias aplicações de + IA, desde chatbots até análise de documentos. + + ) + })} + + {h2({ children: 'Principais modelos disponíveis na AWS' })} + {ul({ + children: ( + <> + {li({ + children: ( + <> + Amazon Titan: Modelo versátil para tarefas do + dia a dia, como responder perguntas, resumir textos e + organizar informações. É uma ótima escolha para quem busca + equilíbrio entre custo e desempenho. + + ) + })} + {li({ + children: ( + <> + Anthropic Claude: Ideal para conversas mais + longas, respostas detalhadas e situações que exigem mais + segurança e controle. Muito usado em chatbots avançados e + assistentes virtuais. + + ) + })} + {li({ + children: ( + <> + AI21 Labs: Especialista em geração de textos + criativos, artigos, e até mesmo conteúdos técnicos. Indicado + para quem precisa de textos mais elaborados ou criativos. + + ) + })} + {li({ + children: ( + <> + Cohere: Focado em análise de linguagem, + classificação de textos e extração de informações. Bom para + automação de processos e análise de grandes volumes de dados. + + ) + })} + + ) + })} + + {h2({ children: 'Quando usar cada modelo?' })} + {ul({ + children: ( + <> + {li({ + children: ( + <> + Precisa de respostas rápidas e gerais? Use o{' '} + Amazon Titan. + + ) + })} + {li({ + children: ( + <> + + Vai criar um chatbot ou assistente virtual que precisa + conversar bastante? + {' '} + O Claude é uma ótima opção. + + ) + })} + {li({ + children: ( + <> + Quer gerar textos criativos ou técnicos?{' '} + Experimente o AI21 Labs. + + ) + })} + {li({ + children: ( + <> + + Precisa analisar, classificar ou organizar muitos textos? + {' '} + O Cohere pode ajudar. + + ) + })} + + ) + })} + + {p({ + children: ( + <> + Com esses modelos, você pode escolher a solução que mais combina com + o seu projeto, sem se preocupar com a complexidade técnica. Basta + selecionar o modelo e começar a usar! + + ) + })} + + ) +} + +export default FoundationModelsPage diff --git a/src/features/docs/pages/integration.tsx b/src/features/docs/pages/integration.tsx new file mode 100644 index 0000000..10a5a86 --- /dev/null +++ b/src/features/docs/pages/integration.tsx @@ -0,0 +1,83 @@ +import { motion } from 'framer-motion' +import { h1, h2, p, ul, li, pre } from '@/shared/components/typography' + +export function IntegrationPage() { + return ( + + {h1({ children: 'Como integrar com sua base de conhecimento' })} + + {h2({ children: 'Integração simples via API' })} + {p({ + children: ( + <> + Depois de criar sua base de conhecimento no Knowly, você pode + integrá-la facilmente ao seu sistema usando nossa API. O + funcionamento é parecido com a API da OpenAI, muito usada em + chatbots de atendimento, como no WhatsApp. + + ) + })} + + {h2({ children: 'Como funciona?' })} + {ul({ + children: ( + <> + {li({ + children: ( + <> + Você envia um prompt (mensagem ou pergunta) + para a API, junto com sua chave de acesso. + + ) + })} + {li({ + children: ( + <> + A API responde com a melhor resposta baseada nos documentos + que você enviou para sua base. + + ) + })} + {li({ + children: ( + <> + É possível usar essa integração em sites, sistemas internos, + chatbots e muito mais. + + ) + })} + + ) + })} + + {h2({ children: 'Exemplo de requisição' })} + {pre({ + children: `POST https://api.knowly.ai/v1/query +Headers: + Authorization: Bearer SUA_CHAVE_DE_API +Body: +{ + "prompt": "Como faço para atualizar meus dados cadastrais?", + "baseId": "sua-base-id" +}` + })} + + {p({ + children: ( + <> + A resposta virá em formato JSON, com o texto gerado pela IA usando o + conhecimento da sua base. Assim, você pode automatizar atendimentos, + responder dúvidas de clientes ou integrar a IA ao seu sistema de + forma simples e eficiente. + + ) + })} + + ) +} + +export default IntegrationPage diff --git a/src/features/docs/pages/knowledge-bases.tsx b/src/features/docs/pages/knowledge-bases.tsx new file mode 100644 index 0000000..78192e0 --- /dev/null +++ b/src/features/docs/pages/knowledge-bases.tsx @@ -0,0 +1,63 @@ +import { motion } from 'framer-motion' +import { h1, h2, p, ul, li } from '@/shared/components/typography' + +export function KnowledgeBasesPage() { + return ( + + {h1({ children: 'Gerenciando Bases de Conhecimento' })} + + {h2({ children: 'Como criar, editar e excluir suas bases' })} + {p({ + children: ( + <> + Gerenciar suas bases de conhecimento no Knowly é simples e rápido. + Basta acessar as configurações do seu perfil após fazer login e + selecionar a opção "Minhas bases". + + ) + })} + + {ul({ + children: ( + <> + {li({ + children: ( + <> + Para criar uma base: Clique em{' '} + "Nova base", escolha um nome e + envie os documentos que deseja usar. Pronto! Sua base estará + disponível para consultas e integrações assim que terminar de + ser criada. + + ) + })} + {li({ + children: ( + <> + Para editar: Selecione uma base existente na + lista para ver detalhes, adicionar ou remover documentos, ou + atualizar informações. + + ) + })} + {li({ + children: ( + <> + Para excluir: Escolha a base desejada e + clique em "Excluir". Confirme a + ação para remover a base do sistema. + + ) + })} + + ) + })} + + ) +} + +export default KnowledgeBasesPage diff --git a/src/features/docs/pages/playground-testing.tsx b/src/features/docs/pages/playground-testing.tsx new file mode 100644 index 0000000..1e7a64e --- /dev/null +++ b/src/features/docs/pages/playground-testing.tsx @@ -0,0 +1,68 @@ +import { motion } from 'framer-motion' +import { h1, h2, p, ul, li } from '@/shared/components/typography' + +export function PlaygroundTestingPage() { + return ( + + {h1({ children: 'Testando sua Base de Conhecimento no Playground' })} + + {h2({ children: 'O que é o Playground?' })} + {p({ + children: ( + <> + O Playground é uma área do nosso site feita para você testar, na + prática, as bases de conhecimento que criou. Ele funciona como um + chat, parecido com o ChatGPT ou o Gemini, onde você pode conversar + com a sua base e ver as respostas geradas pela inteligência + artificial. + + ) + })} + + {h2({ children: 'Como funciona?' })} + {ul({ + children: ( + <> + {li({ + children: <>Escolha qual base de conhecimento você quer testar. + })} + {li({ + children: ( + <> + Envie perguntas ou comandos (prompts) no chat, como faria em + um atendimento real. + + ) + })} + {li({ + children: ( + <> + Veja as respostas geradas na hora, usando os documentos e + informações que você cadastrou. + + ) + })} + + ) + })} + + {h2({ children: 'Por que usar o Playground?' })} + {p({ + children: ( + <> + O Playground é a maneira mais fácil e rápida de conferir se sua base + está funcionando como esperado. Você pode testar diferentes + perguntas, ajustar seus documentos e experimentar os modelos antes + de integrar a IA ao seu site ou sistema. + + ) + })} + + ) +} + +export default PlaygroundTestingPage diff --git a/src/features/docs/pages/subscription-management.tsx b/src/features/docs/pages/subscription-management.tsx new file mode 100644 index 0000000..a6f0251 --- /dev/null +++ b/src/features/docs/pages/subscription-management.tsx @@ -0,0 +1,66 @@ +import { motion } from 'framer-motion' +import { h1, h2, p, ul, li } from '@/shared/components/typography' + +export function SubscriptionManagementPage() { + return ( + + {h1({ children: 'Gerenciando seu Plano de Assinatura' })} + + {h2({ children: 'Como alterar seu plano' })} + {p({ + children: ( + <> + Para gerenciar seu plano de assinatura no Knowly, é só acessar a + área do usuário após fazer login. No menu lateral à esquerda, clique + em "Assinatura". + + ) + })} + {ul({ + children: ( + <> + {li({ + children: ( + <> + Na seção "Plano atual", procure o + botão "Mudar plano" no canto + inferior direito. + + ) + })} + {li({ + children: ( + <> + Clique em "Mudar plano" para ver as + opções disponíveis e escolher o plano que melhor atende às + suas necessidades. + + ) + })} + {li({ + children: ( + <> + Confirme a alteração e pronto! Seu novo plano já estará ativo. + + ) + })} + + ) + })} + {p({ + children: ( + <> + Assim, você pode ajustar sua assinatura sempre que quiser, de forma + rápida e sem complicação. + + ) + })} + + ) +} + +export default SubscriptionManagementPage diff --git a/src/features/home/pages/home-page.tsx b/src/features/home/pages/home-page.tsx index c672251..3c2da9f 100644 --- a/src/features/home/pages/home-page.tsx +++ b/src/features/home/pages/home-page.tsx @@ -4,11 +4,6 @@ import { motion } from 'framer-motion' import { Button } from '@/shared/components/button' import { Accordion, AccordionItem } from '@/shared/components/accordion' import { DotLottieReact } from '@lottiefiles/dotlottie-react' -import berti from '@/assets/berti.jfif' -import izabel from '@/assets/izabel.jfif' -import rubio from '@/assets/rubio.jfif' -import julia from '@/assets/julia.jfif' -import sakamoto from '@/assets/sakamoto.jfif' import { Fragment } from 'react/jsx-runtime' import { Card, @@ -116,30 +111,30 @@ const process = [ const owners = [ { name: 'Enzo Sakamoto', - image: sakamoto, - role: 'Estagiário de Arquitetura de Software na XP Inc.', + image: 'https://d1b8zs4rmj3xdl.cloudfront.net/sakamoto.jpeg', + role: 'Arquiteto de Software Júnior na XP Inc.', linkedin: 'https://www.linkedin.com/in/enzosakamoto/' }, { name: 'Izabel Sampaio Goes', - image: izabel, + image: 'https://d1b8zs4rmj3xdl.cloudfront.net/iza.jpeg', role: 'Estagiária de Ciência de Dados e IA na Evo Systems', linkedin: 'https://www.linkedin.com/in/izabel-sampaio-goes/' }, { name: 'Rafael Rubio', - image: rubio, + image: 'https://d1b8zs4rmj3xdl.cloudfront.net/rubio.png', role: 'Estagiário de Desenvolvimento de Software na Socium', linkedin: 'https://www.linkedin.com/in/rafael-rubio-carnes-b2561b212/' }, { name: 'Júlia Galhardi Cerqueira', - image: julia, + image: 'https://d1b8zs4rmj3xdl.cloudfront.net/julia.jpeg', role: 'Estagiária' }, { name: 'Vinícius Berti', - image: berti, + image: 'https://d1b8zs4rmj3xdl.cloudfront.net/berti.jpeg', role: 'Estagiário de Dados para Antifraude na Capim', linkedin: 'https://www.linkedin.com/in/vinicius-berti-a80354209/' } @@ -147,11 +142,26 @@ const owners = [ function LogoCarousel() { const logos = [ - 'https://store-images.s-microsoft.com/image/apps.22898.1ee6619e-8cd6-4c91-a459-3abe6a6cf7b3.84da202a-9b47-4a76-be2c-79f143c64050.21add500-ed5b-42ee-b87d-ee415ae071fc', - 'https://play-lh.googleusercontent.com/d2zqBFBEymSZKaVg_dRo1gh3hBFn7_Kl9rO74xkDmnJeLgDW0MoJD3cUx0QzZN6jdsg=w240-h480-rw', - 'https://t2.tudocdn.net/716766?w=824&h=494', - 'https://static.vecteezy.com/system/resources/previews/019/136/322/non_2x/amazon-logo-amazon-icon-free-free-vector.jpg', - 'https://azure.microsoft.com/en-us/blog/wp-content/uploads/2024/02/MSFT_Azure_FEB12_319938_Blog_BlogHeader_240226_600x600_V1.jpg' + { + url: 'https://store-images.s-microsoft.com/image/apps.22898.1ee6619e-8cd6-4c91-a459-3abe6a6cf7b3.84da202a-9b47-4a76-be2c-79f143c64050.21add500-ed5b-42ee-b87d-ee415ae071fc', + comingSoon: true + }, // Meta + { + url: 'https://play-lh.googleusercontent.com/d2zqBFBEymSZKaVg_dRo1gh3hBFn7_Kl9rO74xkDmnJeLgDW0MoJD3cUx0QzZN6jdsg=w240-h480-rw', + comingSoon: true + }, // Deep Seek + { + url: 'https://static.vecteezy.com/system/resources/previews/019/136/322/non_2x/amazon-logo-amazon-icon-free-free-vector.jpg', + comingSoon: false + }, // Amazon + { + url: 'https://t2.tudocdn.net/716766?w=824&h=494', + comingSoon: true + }, // Claude Sonnet + { + url: 'https://azure.microsoft.com/en-us/blog/wp-content/uploads/2024/02/MSFT_Azure_FEB12_319938_Blog_BlogHeader_240226_600x600_V1.jpg', + comingSoon: false + } // Mistral ] const allLogos = [...logos, ...logos, ...logos, ...logos] return ( @@ -176,13 +186,18 @@ function LogoCarousel() { }} > {allLogos.map((logo, idx) => ( -
+
Logo de modelo Bedrock + {logo.comingSoon && ( + + Em breve + + )}
))} diff --git a/src/features/login/pages/login.tsx b/src/features/login/pages/login.tsx index e6de017..171a48d 100644 --- a/src/features/login/pages/login.tsx +++ b/src/features/login/pages/login.tsx @@ -2,7 +2,7 @@ import { Background } from '@/shared/components/background' import { Layout } from '@/shared/components/layout' import { motion } from 'framer-motion' import { Button } from '@/shared/components/button' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Eye, EyeOff } from 'lucide-react' import { BackgroundBlobs } from '@/shared/components/background-blobs' import { @@ -28,20 +28,63 @@ import { Form } from '@/shared/components/form' import { Input } from '@/shared/components/input' -import { Link } from 'react-router-dom' +import { Checkbox } from '@/shared/components/checkbox' +import { Link, useNavigate } from 'react-router-dom' import { containerVariants, cardVariants } from '@/shared/utils/animations' +import { useLoginUserMutation } from '@/shared/hooks/use-user' +import toast from 'react-hot-toast' +import { AxiosError } from 'axios' + +const REMEMBER_EMAIL_KEY = 'knowly_remember_email' export function LoginPage() { const [showPassword, setShowPassword] = useState(false) + const [rememberEmail, setRememberEmail] = useState(false) + const { mutateAsync: loginUser, isPending } = useLoginUserMutation() + const navigate = useNavigate() + + // Get saved email before initializing form + const savedEmail = localStorage.getItem(REMEMBER_EMAIL_KEY) const form = useForm({ resolver: zodResolver(loginFormSchema), - defaultValues: loginFormInitialValues, + defaultValues: { + ...loginFormInitialValues, + email: savedEmail || loginFormInitialValues.email + }, mode: 'onBlur' }) - const onSubmit = (values: LoginFormData) => { - console.log(values) + // Set checkbox state on mount + useEffect(() => { + if (savedEmail) { + setRememberEmail(true) + } + }, [savedEmail]) + + const onSubmit = async (values: LoginFormData) => { + try { + // Save or remove email from localStorage based on checkbox + if (rememberEmail) { + localStorage.setItem(REMEMBER_EMAIL_KEY, values.email) + } else { + localStorage.removeItem(REMEMBER_EMAIL_KEY) + } + + const response = await loginUser(values) + localStorage.setItem('token', response.id_token) + localStorage.setItem('refresh_token', response.refresh_token) + toast.success('Login realizado com sucesso!') + navigate('/bases', { + replace: true + }) + } catch (err) { + const errorMessage = + err instanceof AxiosError && err.response?.data?.message + ? err.response.data.message + : 'Erro ao fazer login' + toast.error(errorMessage) + } } return ( @@ -115,9 +158,31 @@ export function LoginPage() { )} /> + + {/* Remember Email Checkbox */} +
+ + setRememberEmail(checked as boolean) + } + /> + +
+ -
Ainda não tem uma conta?{' '} diff --git a/src/features/playground/pages/playground.tsx b/src/features/playground/pages/playground.tsx index f3bc575..67eb205 100644 --- a/src/features/playground/pages/playground.tsx +++ b/src/features/playground/pages/playground.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { useParams, useNavigate } from 'react-router-dom' +import { useParams, useNavigate, useLocation } from 'react-router-dom' import { motion } from 'framer-motion' import { Send, Bot, User, ArrowLeft } from 'lucide-react' import toast from 'react-hot-toast' @@ -36,6 +36,8 @@ type Message = { export function PlaygroundPage() { const { kbId } = useParams<{ kbId: string }>() const navigate = useNavigate() + const location = useLocation() + const kbKey = location.state?.kbKey as string | undefined const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') const [selectedModel, setSelectedModel] = useState( @@ -48,6 +50,19 @@ export function PlaygroundPage() { const currentKnowledgeBase = knowledgeBases?.find((kb) => kb.id === kbId) + useEffect(() => { + // Validate if kbKey is available + if (!kbKey && currentKnowledgeBase) { + const firstKey = currentKnowledgeBase.keys[0]?.kbKey + if (!firstKey) { + toast.error( + 'Nenhuma chave de API disponível para esta base de conhecimento' + ) + navigate('/bases') + } + } + }, [kbKey, currentKnowledgeBase, navigate]) + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } @@ -59,6 +74,14 @@ export function PlaygroundPage() { const handleSendMessage = async () => { if (!inputValue.trim() || !kbId) return + // Get the kbKey to use (from state or from current KB) + const keyToUse = kbKey || currentKnowledgeBase?.keys[0]?.kbKey + + if (!keyToUse) { + toast.error('Chave de API não disponível') + return + } + const userMessage: Message = { id: Date.now().toString(), content: inputValue, @@ -72,6 +95,7 @@ export function PlaygroundPage() { try { const response = await chatMutation.mutateAsync({ kbId, + kbKey: keyToUse, model: selectedModel, prompt: inputValue }) @@ -85,13 +109,11 @@ export function PlaygroundPage() { setMessages((prev) => [...prev, botMessage]) } catch (error) { - console.error('Erro ao enviar mensagem:', error) toast.error('Erro ao enviar mensagem. Tente novamente.') const errorMessage: Message = { id: (Date.now() + 1).toString(), - content: - 'Desculpe, ocorreu um erro ao processar sua mensagem. Tente novamente.', + content: `Desculpe, ocorreu um erro ao processar sua mensagem. Tente novamente. ${(error as { details: string }).details}`, type: 'bot', timestamp: new Date() } @@ -177,7 +199,7 @@ export function PlaygroundPage() {
{/* Chat Container */} - + @@ -231,7 +253,7 @@ export function PlaygroundPage() { : 'bg-muted text-foreground' }`} > -

+

{message.content}

diff --git a/src/features/sign-up/pages/sign-up-page.tsx b/src/features/sign-up/pages/sign-up-page.tsx index e3dc52a..7107a90 100644 --- a/src/features/sign-up/pages/sign-up-page.tsx +++ b/src/features/sign-up/pages/sign-up-page.tsx @@ -38,10 +38,16 @@ import { motion } from 'framer-motion' import { Eye, EyeOff } from 'lucide-react' import { useState } from 'react' import { containerVariants, cardVariants } from '@/shared/utils/animations' +import { useCreateUserMutation } from '@/shared/hooks/use-user' +import { AxiosError } from 'axios' +import { useNavigate } from 'react-router-dom' export function SignUpPage() { const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const navigate = useNavigate() + + const { mutateAsync: createUser, isPending } = useCreateUserMutation() const form = useForm({ resolver: zodResolver(signUpFormSchema), @@ -73,9 +79,39 @@ export function SignUpPage() { onChange(formattedValue) } - function onSubmit(values: SignUpFormData) { - console.log(values) - toast.success('Conta criada com sucesso!') + async function onSubmit(values: SignUpFormData) { + try { + const isIndividual = values.documentType === 'individual' + + // Convert birthDate to seconds since epoch if provided + const birthDateSeconds = values.birthDate + ? Math.floor(values.birthDate.getTime() / 1000) + : undefined + + await createUser({ + name: values.name, + email: values.email, + password: values.password, + cellphone: values.phone, + personType: isIndividual ? 'PF' : 'PJ', + cpfCnpj: values.document.replace(/\D/g, ''), + birthDate: birthDateSeconds, + plan: 'Bronze' + }) + + form.reset() + toast.success( + 'Conta criada! Confirme seu cadastro pelo link enviado para o seu e-mail. Você será redirecionado para a página de login.', + { duration: 10000 } + ) + setTimeout(() => { + navigate('/login', { replace: true }) + }, 10000) + } catch (err) { + const errorMessage = + ((err as AxiosError).response?.data as string) || 'Erro ao fazer login' + toast.error(errorMessage) + } } return ( @@ -326,7 +362,9 @@ export function SignUpPage() { /> - + diff --git a/src/features/user-area/components/knowledge-base-detail-modal.tsx b/src/features/user-area/components/knowledge-base-detail-modal.tsx index 1834e8b..3145035 100644 --- a/src/features/user-area/components/knowledge-base-detail-modal.tsx +++ b/src/features/user-area/components/knowledge-base-detail-modal.tsx @@ -1,5 +1,18 @@ import { useNavigate } from 'react-router-dom' -import { FileText, Trash2, Plus, Play } from 'lucide-react' +import { useState } from 'react' +import { + FileText, + Trash2, + Plus, + Play, + Eye, + EyeOff, + Copy, + Check, + Code2, + ChevronDown, + ChevronUp +} from 'lucide-react' import { Dialog, DialogContent, @@ -24,6 +37,11 @@ export function KnowledgeBaseDetailModal({ onOpenChange }: KnowledgeBaseDetailModalProps) { const navigate = useNavigate() + const [visibleKeys, setVisibleKeys] = useState>(new Set()) + const [copiedKeys, setCopiedKeys] = useState>(new Set()) + const [selectedLanguage, setSelectedLanguage] = useState('javascript') + const [copiedCode, setCopiedCode] = useState(false) + const [isCodeSectionExpanded, setIsCodeSectionExpanded] = useState(false) const formatFileSize = (sizeInMB: number) => { if (sizeInMB < 1) { @@ -35,7 +53,7 @@ export function KnowledgeBaseDetailModal({ const getStatusColor = (status: string) => { switch (status.toLowerCase()) { case 'active': - return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' + return 'bg-green-600' case 'processing': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' case 'error': @@ -56,10 +74,202 @@ export function KnowledgeBaseDetailModal({ } const handleTestKnowledgeBase = () => { - navigate(`/playground/${knowledgeBase.id}`) + // Get the first available key + const firstKey = knowledgeBase.keys[0]?.kbKey + + navigate(`/playground/${knowledgeBase.id}`, { + state: { kbKey: firstKey } + }) onOpenChange(false) } + const toggleKeyVisibility = (keyId: string) => { + setVisibleKeys((prev) => { + const newSet = new Set(prev) + if (newSet.has(keyId)) { + newSet.delete(keyId) + } else { + newSet.add(keyId) + } + return newSet + }) + } + + const copyKeyToClipboard = async (key: string, keyId: string) => { + try { + await navigator.clipboard.writeText(key) + setCopiedKeys((prev) => new Set(prev).add(keyId)) + setTimeout(() => { + setCopiedKeys((prev) => { + const newSet = new Set(prev) + newSet.delete(keyId) + return newSet + }) + }, 2000) + } catch (err) { + console.error('Erro ao copiar chave:', err) + } + } + + const maskKey = (key: string) => { + return '•'.repeat(key.length) + } + + const getCodeSnippet = (language: string, apiKey: string) => { + const apiUrl = 'https://api.knowly.dev.br/chat' + + switch (language) { + case 'javascript': + return `// JavaScript +fetch('${apiUrl}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + kb_key: '${apiKey}', + model: 'MISTRAL_SMALL', + prompt: 'Sua pergunta aqui' + }) +}) + .then(response => response.json()) + .then(data => console.log(data.response));` + + case 'python': + return `# Python +import requests + +response = requests.post( + '${apiUrl}', + headers={ + 'Content-Type': 'application/json', + }, + json={ + 'kb_key': '${apiKey}', + 'model': 'MISTRAL_SMALL', + 'prompt': 'Sua pergunta aqui' + } +) + +data = response.json() +print(data['response'])` + + case 'java': + return `// Java +import java.net.http.*; +import java.net.URI; + +HttpClient client = HttpClient.newHttpClient(); +HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("${apiUrl}")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\\"prompt\\":\\"Sua pergunta aqui\\",\\"model\\":\\"MISTRAL_SMALL\\",\\"kb_key\\":\\"${apiKey}\\"}" + )) + .build(); + +HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofString()); +System.out.println(response.body());` + + case 'csharp': + return `// C# (.NET) +using System.Net.Http; +using System.Text; +using System.Text.Json; + +var client = new HttpClient(); + +var content = new StringContent( + JsonSerializer.Serialize(new { + prompt = "Sua pergunta aqui", + model = "MISTRAL_SMALL", + kb_key = "${apiKey}" + }), + Encoding.UTF8, + "application/json" +); + +var response = await client.PostAsync("${apiUrl}", content); +var result = await response.Content.ReadAsStringAsync(); +Console.WriteLine(result);` + + case 'go': + return `// Go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +func main() { + payload := map[string]interface{}{ + "prompt": "Sua pergunta aqui", + "kb_key": "${apiKey}", + "model": "MISTRAL_SMALL" + } + jsonData, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "${apiUrl}", + bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, _ := client.Do(req) + defer resp.Body.Close() +}` + + case 'php': + return ` 'Sua pergunta aqui', + 'kb_key' => '${apiKey}', + 'model' => 'MISTRAL_SMALL' +])); + +$response = curl_exec($ch); +curl_close($ch); + +$data = json_decode($response, true); +echo $data['response'];` + + default: + return '' + } + } + + const copyCodeToClipboard = async () => { + const firstKey = knowledgeBase.keys[0]?.kbKey || 'YOUR_API_KEY' + const code = getCodeSnippet(selectedLanguage, firstKey) + + try { + await navigator.clipboard.writeText(code) + setCopiedCode(true) + setTimeout(() => setCopiedCode(false), 2000) + } catch (err) { + console.error('Erro ao copiar código:', err) + } + } + + const languages = [ + { id: 'javascript', name: 'JavaScript', icon: '📙' }, + { id: 'python', name: 'Python', icon: '🐍' }, + { id: 'java', name: 'Java', icon: '☕' }, + { id: 'csharp', name: 'C# (.NET)', icon: '🔷' }, + { id: 'go', name: 'Go', icon: '🐹' }, + { id: 'php', name: 'PHP', icon: '🐘' } + ] + return (

@@ -107,6 +317,154 @@ export function KnowledgeBaseDetailModal({ + {/* API Keys */} +
+

API Keys

+ {knowledgeBase.keys.length === 0 ? ( +
+ Nenhuma chave de API encontrada +
+ ) : ( +
+ {knowledgeBase.keys.map((key, index) => { + const keyId = `${key.kbKey}-${index}` + const isVisible = visibleKeys.has(keyId) + const isCopied = copiedKeys.has(keyId) + + return ( +
+
+ + {key.kbKeyAlias} + +
+
+
+ {isVisible ? key.kbKey : maskKey(key.kbKey)} +
+ + +
+
+ ) + })} +
+ )} +
+ + + + {/* Code Snippets */} +
+ + +
+
+ {knowledgeBase.keys.length === 0 ? ( +
+ Crie uma API Key para ver os exemplos de código +
+ ) : ( +
+ {/* Language selector */} +
+ {languages.map((lang) => ( + + ))} +
+ + {/* Code snippet */} +
+
+                        
+                          {getCodeSnippet(
+                            selectedLanguage,
+                            knowledgeBase.keys[0]?.kbKey || 'YOUR_API_KEY'
+                          )}
+                        
+                      
+ +
+
+ )} +
+
+
+ + + {/* Lista de arquivos */}
diff --git a/src/features/user-area/pages/create-base-pipeline.tsx b/src/features/user-area/pages/create-base-pipeline.tsx index 404f679..a74fdab 100644 --- a/src/features/user-area/pages/create-base-pipeline.tsx +++ b/src/features/user-area/pages/create-base-pipeline.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' import { @@ -48,6 +48,9 @@ export function CreateBasePipeline() { const createKnowledgeBaseMutation = useCreateKnowledgeBaseMutation() + // Guard to prevent double-run under React StrictMode in development + const didRunRef = useRef(false) + const [pipeline, setPipeline] = useState({ currentStep: 0, steps: [ @@ -143,81 +146,108 @@ export function CreateBasePipeline() { } try { - // Step 1: Create Knowledge Base - updateStepStatus(0, 'loading') - const createResult = await createKnowledgeBaseMutation.mutateAsync({ - name: formData.slug, - displayName: formData.name, - description: formData.description - }) - - setPipeline((prev) => ({ ...prev, kbId: createResult.kb_id })) - updateStepStatus(0, 'success') - moveToNextStep() + let kbId = pipeline.kbId + + // Step 1: Create Knowledge Base (skip if already created) + if (pipeline.steps[0].status !== 'success') { + updateStepStatus(0, 'loading') + const createResult = await createKnowledgeBaseMutation.mutateAsync({ + name: formData.slug, + displayName: formData.name, + description: formData.description + }) + + kbId = createResult.kb_id + setPipeline((prev) => ({ ...prev, kbId })) + updateStepStatus(0, 'success') + moveToNextStep() + } else { + // Already succeeded, just move forward + console.log('Step 1 já concluído, pulando...') + setPipeline((prev) => ({ + ...prev, + currentStep: Math.max(prev.currentStep, 1) + })) + } - // Step 2: Upload Files - updateStepStatus(1, 'loading') + if (!kbId) { + throw new Error('KB ID não encontrado') + } - const presignedResponse = await knowledgeBaseService.getUrlPresigned({ - bucketName, - kbId: createResult.kb_id - }) + // Step 2: Upload Files (skip if already uploaded) + if (pipeline.steps[1].status !== 'success') { + updateStepStatus(1, 'loading') - for (let i = 0; i < formData.files.length; i++) { - const file = formData.files[i] + const presignedResponse = await knowledgeBaseService.getUrlPresigned({ + bucketName, + kbId + }) - try { - // Upload file to S3 - await uploadFileToS3( - file, - presignedResponse.url, - presignedResponse.fields - ) + for (let i = 0; i < formData.files.length; i++) { + const file = formData.files[i] - // Update description to show progress - setPipeline((prev) => ({ - ...prev, - steps: prev.steps.map((step, index) => - index === 1 - ? { - ...step, - description: `Enviando arquivo ${i + 1} de ${ - formData.files.length - }: ${file.name}` - } - : step + try { + // Upload file to S3 + await uploadFileToS3( + file, + presignedResponse.url, + presignedResponse.fields ) - })) - } catch (error) { - console.error(`Error uploading file ${file.name}:`, error) - updateStepStatus(1, 'error', `Erro ao enviar arquivo: ${file.name}`) - return + + // Update description to show progress + setPipeline((prev) => ({ + ...prev, + steps: prev.steps.map((step, index) => + index === 1 + ? { + ...step, + description: `Enviando arquivo ${i + 1} de ${ + formData.files.length + }: ${file.name}` + } + : step + ) + })) + } catch (error) { + console.error(`Error uploading file ${file.name}:`, error) + updateStepStatus(1, 'error', `Erro ao enviar arquivo: ${file.name}`) + return + } } + + updateStepStatus(1, 'success') + setPipeline((prev) => ({ + ...prev, + steps: prev.steps.map((step, index) => + index === 1 + ? { + ...step, + description: `${formData.files.length} arquivos enviados com sucesso` + } + : step + ) + })) + moveToNextStep() + } else { + // Already succeeded, just move forward + console.log('Step 2 já concluído, pulando...') + setPipeline((prev) => ({ + ...prev, + currentStep: Math.max(prev.currentStep, 2) + })) } - updateStepStatus(1, 'success') - setPipeline((prev) => ({ - ...prev, - steps: prev.steps.map((step, index) => - index === 1 - ? { - ...step, - description: `${formData.files.length} arquivos enviados com sucesso` - } - : step - ) - })) - moveToNextStep() - - // Step 3: Sync Knowledge Base - updateStepStatus(2, 'loading') - await new Promise((resolve) => setTimeout(resolve, 5000)) // Sleep - await knowledgeBaseService.syncKnowledgeBase({ - bucketName, - kbId: createResult.kb_id - }) + // Step 3: Sync Knowledge Base (skip if already synced) + if (pipeline.steps[2].status !== 'success') { + updateStepStatus(2, 'loading') + await new Promise((resolve) => setTimeout(resolve, 5000)) // Sleep + await knowledgeBaseService.syncKnowledgeBase({ + bucketName, + kbId + }) - updateStepStatus(2, 'success') + updateStepStatus(2, 'success') + } // Success! toast.success('Base de conhecimento criada com sucesso!') @@ -234,11 +264,16 @@ export function CreateBasePipeline() { 'error', 'Erro inesperado durante o processo' ) - toast.error('Erro ao criar base de conhecimento') + toast.error( + 'Erro ao criar base de conhecimento: ' + (error as Error).message + ) } } useEffect(() => { + if (didRunRef.current) return + didRunRef.current = true + if (formData) { executeSteps() } else { @@ -246,6 +281,31 @@ export function CreateBasePipeline() { } }, []) + const handleRetry = () => { + // Reset error states but keep success states + setPipeline((prev) => ({ + ...prev, + steps: prev.steps.map((step) => + step.status === 'error' + ? { ...step, status: 'pending', errorMessage: undefined } + : step + ) + })) + + // Show info about resuming + const successCount = pipeline.steps.filter( + (s) => s.status === 'success' + ).length + if (successCount > 0) { + toast.success( + `Retomando do ponto de falha. ${successCount} etapa(s) já concluída(s) serão puladas.` + ) + } + + // Execute steps again (will skip already successful steps) + executeSteps() + } + const getStepIcon = (step: PipelineStep) => { switch (step.status) { case 'loading': @@ -382,10 +442,16 @@ export function CreateBasePipeline() { - +
)} diff --git a/src/features/user-area/pages/create-base.tsx b/src/features/user-area/pages/create-base.tsx index da06269..3d450f0 100644 --- a/src/features/user-area/pages/create-base.tsx +++ b/src/features/user-area/pages/create-base.tsx @@ -5,7 +5,7 @@ import { motion } from 'framer-motion' import { Save, ArrowLeft } from 'lucide-react' import { useNavigate } from 'react-router-dom' import toast from 'react-hot-toast' - +import { v4 as uuidv4 } from 'uuid' import { Background } from '@/shared/components/background' import { Layout } from '@/shared/components/layout' import { Button } from '@/shared/components/button' @@ -64,7 +64,7 @@ export function CreateBase() { .replace(/[\u0300-\u036f]/g, '') // Remove diacritics .replace(/[^a-z0-9\s]/g, '-') .replace(/\s+/g, '-') - data.slug = slug + data.slug = slug.concat('-', uuidv4().slice(0, 8)) // Append random string to ensure uniqueness // Navigate to pipeline page with form data navigate('/bases/create/pipeline', { state: { formData: data } }) } catch (error) { diff --git a/src/features/user-area/pages/user-info.tsx b/src/features/user-area/pages/user-info.tsx index 221996b..2f7ac09 100644 --- a/src/features/user-area/pages/user-info.tsx +++ b/src/features/user-area/pages/user-info.tsx @@ -29,21 +29,32 @@ import { formatPhone } from '@/shared/utils/format-documents' import { Background } from '@/shared/components/background' - -// Usuário simulado (como se estivesse logado) -const mockUser: UserInfoData & { id: string } = { - id: '1', - name: 'Maria da Silva', - email: 'maria@email.com', - documentType: DOCUMENT_TYPE.INDIVIDUAL, - phone: formatPhone('11912345678'), - document: '234.781.938-47', - birthDate: new Date(2001, 7, 12) // Começa no mês 0 -} +import { useUpdateUserMutation, useUser } from '@/shared/hooks/use-user' +import { UpdateUserRequest } from '@/services/user' +import toast from 'react-hot-toast' export function UserInfoPage() { const [isEditing, setIsEditing] = useState(false) - const [user, setUser] = useState(mockUser) + const { user, isPending, refetch } = useUser() + const { mutateAsync: updateUser, isPending: isUpdating } = + useUpdateUserMutation() + + const form = useForm({ + resolver: zodResolver(userInfoSchema), + defaultValues: { + name: user?.name || '', + email: user?.email || '', + cellphone: user?.cellphone || '', + personType: user?.personType || 'individual', + cpfCnpj: user?.cpfCnpj || '', + birthDate: user?.birthDate + }, + mode: 'onBlur' + }) + + if (isPending || !user) { + return
Loading
+ } const handleCPFChange = ( value: string, @@ -69,49 +80,60 @@ export function UserInfoPage() { onChange(formattedValue) } - const form = useForm({ - resolver: zodResolver(userInfoSchema), - defaultValues: { - name: user.name, - email: user.email, - phone: user.phone, - documentType: user.documentType, - document: user.document, - birthDate: user.birthDate - }, - mode: 'onBlur' - }) - function handleEdit() { + if (!user) return + setIsEditing(true) form.reset({ name: user.name, email: user.email, - phone: user.phone, - documentType: user.documentType, - document: user.document, + cellphone: user.cellphone, + personType: user.personType, + cpfCnpj: user.cpfCnpj, birthDate: user.birthDate }) } function handleCancel() { + if (!user) return + setIsEditing(false) form.reset({ name: user.name, email: user.email, - phone: user.phone, - documentType: user.documentType, - document: user.document, + cellphone: user.cellphone, + personType: user.personType, + cpfCnpj: user.cpfCnpj, birthDate: user.birthDate }) } - function onSubmit(data: UserInfoData) { - setUser({ - ...user, - ...data + async function onSubmit(data: UserInfoData) { + const dirtyFields = form.formState.dirtyFields as Partial< + Record + > + + const changedData: Partial = {} + + function setField(k: K) { + changedData[k] = data[k] + } + + ;(Object.keys(dirtyFields) as (keyof UserInfoData)[]).forEach((key) => { + if (dirtyFields[key]) { + setField(key) + } }) - setIsEditing(false) + + try { + await updateUser(changedData as UpdateUserRequest) + refetch?.() + } catch (error) { + console.error('Failed to update user:', error) + toast.error('Erro ao atualizar os dados. Tente novamente.') + } finally { + setIsEditing(false) + } } return ( @@ -140,13 +162,13 @@ export function UserInfoPage() {
- +
Documento @@ -214,7 +236,7 @@ export function UserInfoPage() { /> ( Telefone/Celular @@ -235,7 +257,7 @@ export function UserInfoPage() { /> ( Tipo de Pessoa @@ -244,7 +266,7 @@ export function UserInfoPage() { ( Documento @@ -272,7 +294,7 @@ export function UserInfoPage() { {...field} onChange={(e) => { if ( - form.getValues('documentType') === + form.getValues('personType') === 'individual' ) { handleCPFChange( @@ -292,7 +314,7 @@ export function UserInfoPage() { )} /> - {user.documentType === DOCUMENT_TYPE.INDIVIDUAL && ( + {user.personType === DOCUMENT_TYPE.INDIVIDUAL && ( Cancelar diff --git a/src/features/user-area/types/user-info-schema.ts b/src/features/user-area/types/user-info-schema.ts index 42b60ae..78a86c4 100644 --- a/src/features/user-area/types/user-info-schema.ts +++ b/src/features/user-area/types/user-info-schema.ts @@ -9,10 +9,10 @@ export const userInfoSchema = z .min(4, { message: 'O nome deve ter pelo menos 4 caracteres.' }) .max(50, { message: 'O nome deve ter no máximo 50 caracteres.' }), email: z.string().email({ message: 'O e-mail deve ser válido.' }), - phone: z + cellphone: z .string() .min(11, { message: 'O telefone deve ter pelo menos 11 dígitos.' }), - documentType: z + personType: z .string() .refine( (val) => Object.values(DOCUMENT_TYPE).includes(val as DOCUMENT_TYPE), @@ -20,7 +20,7 @@ export const userInfoSchema = z message: 'Selecione um tipo de pessoa válido.' } ), - document: z.string().min(1, { message: 'O documento é obrigatório.' }), + cpfCnpj: z.string().min(1, { message: 'O documento é obrigatório.' }), birthDate: z .date() .refine((date) => !isNaN(date.getTime()), { @@ -38,7 +38,7 @@ export const userInfoSchema = z .optional() }) .superRefine((data, ctx) => { - if (data.documentType === 'individual' && !data.birthDate) { + if (data.personType === DOCUMENT_TYPE.INDIVIDUAL && !data.birthDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'A data de nascimento é obrigatória para pessoa física.', @@ -46,20 +46,20 @@ export const userInfoSchema = z }) } - if (data.documentType === 'individual') { + if (data.personType === DOCUMENT_TYPE.INDIVIDUAL) { // Validação CPF const cpfRegex = /^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$/ - if (!cpfRegex.test(data.document)) { + if (!cpfRegex.test(data.cpfCnpj)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CPF inválido. Use o formato: 000.000.000-00', path: ['document'] }) } - } else if (data.documentType === 'business') { + } else if (data.personType === DOCUMENT_TYPE.BUSINESS) { // Validação CNPJ const cnpjRegex = /^(\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2})$/ - if (!cnpjRegex.test(data.document)) { + if (!cnpjRegex.test(data.cpfCnpj)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CNPJ inválido. Use o formato: 00.000.000/0000-00', diff --git a/src/routes/app-routes.tsx b/src/routes/app-routes.tsx index 507a518..a2d14f9 100644 --- a/src/routes/app-routes.tsx +++ b/src/routes/app-routes.tsx @@ -19,6 +19,13 @@ import { PageWithSidebar } from '@/shared/components/page-with-sidebar' import { CreateBase } from '@/features/user-area/pages/create-base' import { CreateBasePipeline } from '@/features/user-area/pages/create-base-pipeline' import { PlaygroundPage } from '@/features/playground/pages/playground' +import { DocsLayout } from '@/shared/components/docs-layout' +import { AboutProjectPage } from '@/features/docs/pages/about-project' +import { FoundationModelsPage } from '@/features/docs/pages/foundation-models' +import { KnowledgeBasesPage } from '@/features/docs/pages/knowledge-bases' +import { IntegrationPage } from '@/features/docs/pages/integration' +import { PlaygroundTestingPage } from '@/features/docs/pages/playground-testing' +import { SubscriptionManagementPage as DocsSubscriptionPage } from '@/features/docs/pages/subscription-management' export function AppRoutes() { return ( @@ -60,6 +67,24 @@ export function AppRoutes() { } /> } /> + + }> + } /> + } + /> + } /> + } /> + } + /> + } + /> + ) diff --git a/src/services/knowledge-base.ts b/src/services/knowledge-base.ts index 4751665..d8ef595 100644 --- a/src/services/knowledge-base.ts +++ b/src/services/knowledge-base.ts @@ -1,5 +1,5 @@ import { KnowledgeBase } from '@/domain/knowledge-base' -import { api } from '@/shared/api' +import { api, apiChat, getAuthHeader } from '@/shared/api' import { MODELS } from '@/shared/enums/models' import { STATUS } from '@/shared/enums/status' @@ -58,11 +58,16 @@ export type GetKnowledgeBaseResponse = { url: string }[] total_size_mb: number + keys: { + kb_key: string + kb_key_alias: string + }[] }[] } export type ChatWithKnowledgeBaseRequest = { kbId: string + kbKey: string model: MODELS prompt: string topK?: number @@ -79,9 +84,9 @@ export const knowledgeBaseService = { // If kbId is not provided, fetch all knowledge bases const response = await api.get('/kb', { params: { - user_id: 'a9de692c-0ee3-41c6-aecc-44e79b8d739e', // This should be replaced with the actual user ID from your authentication context kb_id: request.kbId - } + }, + headers: getAuthHeader() }) const data = response.data.knowledge_bases return data.map((kb) => { @@ -98,7 +103,11 @@ export const knowledgeBaseService = { sizeMB: file.size_bytes / (1024 * 1024), url: file.url })), - totalSizeMB: kb.total_size_mb + totalSizeMB: kb.total_size_mb, + keys: kb.keys.map((key) => ({ + kbKey: key.kb_key, + kbKeyAlias: key.kb_key_alias + })) } }) }, @@ -106,11 +115,17 @@ export const knowledgeBaseService = { async createKnowledgeBase( request: CreateKnowledgeBaseRequest ): Promise { - const response = await api.post('/kb', { - kb_name: request.name, - kb_display_name: request.displayName, - kb_description: request.description - }) + const response = await api.post( + '/kb', + { + kb_name: request.name, + kb_display_name: request.displayName, + kb_description: request.description + }, + { + headers: getAuthHeader() + } + ) return response.data }, @@ -122,9 +137,9 @@ export const knowledgeBaseService = { { params: { bucket: request.bucketName, - user_id: 'a9de692c-0ee3-41c6-aecc-44e79b8d739e', // This should be replaced with the actual user ID from your authentication context kb_id: request.kbId - } + }, + headers: getAuthHeader() } ) return response.data @@ -134,9 +149,9 @@ export const knowledgeBaseService = { await api.get('/kb/sync', { params: { bucket_name: request.bucketName, - user_id: 'a9de692c-0ee3-41c6-aecc-44e79b8d739e', // This should be replaced with the actual user ID from your authentication context kb_id: request.kbId - } + }, + headers: getAuthHeader() }) }, @@ -144,22 +159,29 @@ export const knowledgeBaseService = { await api.delete('/kb/file', { params: { bucket: request.bucketName, - user_id: 'a9de692c-0ee3-41c6-aecc-44e79b8d739e', // This should be replaced with the actual user ID from your authentication context kb_id: request.kbId, file_name: request.fileName - } + }, + headers: getAuthHeader() }) }, async chatWithKnowledgeBase( request: ChatWithKnowledgeBaseRequest ): Promise { - const response = await api.post('/chat', { - kb_id: request.kbId, - model: request.model, - prompt: request.prompt, - top_k: request.topK - }) + const response = await apiChat.post( + '/chat', + { + kb_id: request.kbId, + kb_key: request.kbKey, + model: request.model, + prompt: request.prompt, + top_k: request.topK + }, + { + headers: getAuthHeader() + } + ) return response.data } } diff --git a/src/services/user.ts b/src/services/user.ts new file mode 100644 index 0000000..b679c4d --- /dev/null +++ b/src/services/user.ts @@ -0,0 +1,119 @@ +import { api, getAuthHeader } from '@/shared/api' +import { User } from '@/shared/domain/user' + +export type CreateUserRequest = { + name: string + email: string + password: string + cellphone: string + personType: string + cpfCnpj: string + birthDate?: number // seconds since epoch (optional for business) + plan: string +} + +export type UpdateUserRequest = { + name?: string + cellphone?: string + plan?: string +} + +export type LoginUserRequest = { + email: string + password: string +} + +export type LoginUserResponse = { + id_token: string + access_token: string + refresh_token: string +} + +export type UserResponse = { + user: { + user_id: string + name: string + email: string + cellphone: string + p_type: string + cpf_cnpj: string + address: string + cep: string + birthdate: number | null + plan: string + creation_date: number + update_date: number + } +} + +export const userService = { + async login(request: LoginUserRequest): Promise { + const response = await api.post('/auth', { + email: request.email, + password: request.password + }) + return response.data + }, + + async refreshToken( + refreshToken: string + ): Promise> { + const response = await api.post('/auth/refresh', { + refresh_token: refreshToken + }) + return response.data + }, + + async createUser(request: CreateUserRequest): Promise { + const payload: Record = { + name: request.name, + email: request.email, + password: request.password, + cellphone: request.cellphone, + p_type: request.personType, + cpf_cnpj: request.cpfCnpj, + address: 'Rua x, 123', // Placeholder address + cep: '00000000', // Placeholder CEP + plan: request.plan + } + + if (typeof request.birthDate === 'number') { + payload.birth_date = request.birthDate + } + + await api.post('/user', payload) + }, + + async updateUser(request: UpdateUserRequest): Promise { + const payload = { + new_name: request.name, + new_cellphone: request.cellphone, + new_plan: request.plan + } + + await api.patch('/user', payload, { + headers: getAuthHeader() + }) + }, + + async getUser(): Promise { + const response = await api.get('/user', { + headers: getAuthHeader() + }) + const data = response.data.user + return { + id: data.user_id, + name: data.name, + email: data.email, + cellphone: data.cellphone, + personType: data.p_type, + cpfCnpj: data.cpf_cnpj, + address: data.address, + cep: data.cep, + birthDate: data.birthdate ? new Date(data.birthdate * 1000) : undefined, + plan: data.plan, + creationDate: data.creation_date, + updateDate: data.update_date + } + } +} diff --git a/src/shared/api.ts b/src/shared/api.ts index cea8cc0..0c6a2ca 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,5 +1,10 @@ -import { apiUrl } from '@/shared/enviroment' -import axios from 'axios' +import { apiChatUrl, apiUrl } from '@/shared/enviroment' +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' + +// Extend Axios request config to include _retry flag +interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean +} export const api = axios.create({ baseURL: apiUrl, @@ -8,3 +13,119 @@ export const api = axios.create({ Accept: 'application/json' } }) + +export const apiChat = axios.create({ + baseURL: apiChatUrl, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + } +}) + +export const getAuthHeader = () => { + const token = localStorage.getItem('token') || '' + return { + Authorization: `Bearer ${token}` + } +} + +// Axios interceptor to handle 401 errors and refresh token +let isRefreshing = false +let failedQueue: Array<{ + resolve: (value?: unknown) => void + reject: (reason?: unknown) => void +}> = [] + +const processQueue = (error: Error | null, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error) + } else { + prom.resolve(token) + } + }) + + failedQueue = [] +} + +// Axios interceptor to handle 401 errors and refresh token +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as ExtendedAxiosRequestConfig + + // Check for network errors (likely 401/expired token) + if (error.code === 'ERR_NETWORK' && !error.response) { + // If already refreshing, queue this request + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then((token) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}` + } + return api(originalRequest) + }) + .catch((err) => { + return Promise.reject(err) + }) + } + + // Mark that we haven't retried this request yet + if (originalRequest._retry) { + // If we already tried to refresh for this request, give up + localStorage.removeItem('token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return Promise.reject(error) + } + + const refreshToken = localStorage.getItem('refresh_token') + + if (refreshToken) { + originalRequest._retry = true + isRefreshing = true + + try { + const response = await api.post('/auth/refresh', { + refresh_token: refreshToken + }) + + const { + id_token + // refresh_token: newRefreshToken + } = response.data + localStorage.setItem('token', id_token) + // localStorage.setItem('refresh_token', newRefreshToken) + + // Process queued requests with new token + processQueue(null, id_token) + + // Retry original request with new token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${id_token}` + } + isRefreshing = false + return api(originalRequest) + } catch (refreshError) { + processQueue(refreshError as Error, null) + isRefreshing = false + localStorage.removeItem('token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return Promise.reject(refreshError) + } + } + + // If no refresh token, redirect to login + processQueue(error, null) + localStorage.removeItem('token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return Promise.reject(error) + } + + return Promise.reject(error) + } +) diff --git a/src/shared/components/docs-layout.tsx b/src/shared/components/docs-layout.tsx new file mode 100644 index 0000000..a3a9428 --- /dev/null +++ b/src/shared/components/docs-layout.tsx @@ -0,0 +1,26 @@ +import { Outlet } from 'react-router-dom' +import { Navbar } from '@/shared/components/navbar' +import { Toaster } from 'react-hot-toast' +import { DocsSidebar } from '@/shared/components/docs-sidebar' +import { Background } from '@/shared/components/background' +import { BackgroundBlobs } from '@/shared/components/background-blobs' + +export function DocsLayout() { + return ( + <> + + + + + +
+
+
+ +
+
+
+
+ + ) +} diff --git a/src/shared/components/docs-sidebar.tsx b/src/shared/components/docs-sidebar.tsx new file mode 100644 index 0000000..e34d2da --- /dev/null +++ b/src/shared/components/docs-sidebar.tsx @@ -0,0 +1,207 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { Link, useLocation } from 'react-router-dom' +import { cn } from '@/shared/utils/cn' +import { + BookOpen, + Boxes, + Database, + Plug, + TestTube, + CreditCard, + Menu, + X +} from 'lucide-react' +import { useState, useEffect } from 'react' +import { Button } from '@/shared/components/button' + +export type DocsSidebarItem = { + label: string + icon: React.ComponentType<{ className?: string }> + path: string +} + +const docsItems: DocsSidebarItem[] = [ + { + label: 'Sobre o Projeto', + icon: BookOpen, + path: '/docs' + }, + { + label: 'Modelos de Fundação', + icon: Boxes, + path: '/docs/foundation-models' + }, + { + label: 'Bases de Conhecimento', + icon: Database, + path: '/docs/knowledge-bases' + }, + { + label: 'Integração', + icon: Plug, + path: '/docs/integration' + }, + { + label: 'Playground e Testes', + icon: TestTube, + path: '/docs/playground-testing' + }, + { + label: 'Gerenciar Assinatura', + icon: CreditCard, + path: '/docs/subscription-management' + } +] + +export function DocsSidebar() { + const location = useLocation() + const [isCollapsed, setIsCollapsed] = useState(false) + const [isMobileOpen, setIsMobileOpen] = useState(false) + + function toggleSidebar() { + setIsCollapsed(!isCollapsed) + } + + function toggleMobileSidebar() { + setIsMobileOpen(!isMobileOpen) + } + + function closeMobileSidebar() { + setIsMobileOpen(false) + } + + // Close mobile sidebar on route change + useEffect(() => { + setIsMobileOpen(false) + }, [location.pathname]) + + const transition = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } + const variants = { + hidden: { filter: 'blur(10px)', opacity: 0 }, + visible: { filter: 'blur(0)', opacity: 1 } + } + + return ( + <> + {/* Mobile menu button */} + + + {/* Mobile backdrop */} + + {isMobileOpen && ( + + )} + + + {/* Sidebar */} + +
+ {/* Desktop collapse button */} + + + + + {/* Mobile close button */} + + + + + {(!isCollapsed || isMobileOpen) && ( + +

+ Documentação +

+
+ )} + + + {docsItems.map((item) => { + const isActive = location.pathname === item.path + const IconComponent = item.icon + return ( + + + + {(!isCollapsed || isMobileOpen) && ( + {item.label} + )} + + + ) + })} + +
+
+ + ) +} diff --git a/src/shared/components/navbar.tsx b/src/shared/components/navbar.tsx index 2ec3656..a00f9b0 100644 --- a/src/shared/components/navbar.tsx +++ b/src/shared/components/navbar.tsx @@ -23,6 +23,9 @@ export function Navbar() { const { theme, setTheme } = useTheme() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + // Determine if user is logged in based on presence of token in localStorage + const isLoggedIn = Boolean(localStorage.getItem('token')) + const toggleMobileMenu = () => { setIsMobileMenuOpen(!isMobileMenuOpen) } @@ -77,6 +80,9 @@ export function Navbar() { Preços + + Documentação + FAQ @@ -106,9 +112,9 @@ export function Navbar() { @@ -142,9 +148,9 @@ export function Navbar() {