├── public └── .nojekyll ├── postcss.config.js ├── src ├── utils │ ├── config.js │ └── cn.js ├── main.jsx ├── context │ ├── SettingsContextDefinition.js │ └── SettingsContext.jsx ├── components │ ├── Layout.jsx │ ├── Settings.jsx │ ├── Sidebar.jsx │ └── ChatWithGemini.jsx ├── App.jsx ├── index.css ├── icons │ └── google.svg ├── service │ └── gemini.service.js ├── App.css └── hooks │ └── useGemini.jsx ├── vite.config.js ├── .gitignore ├── .eslintrc.cjs ├── index.html ├── tailwind.config.js ├── LICENSE ├── package.json ├── .github └── workflows │ └── deployment.yml └── README.md /public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | const env = import.meta.env.MODE 2 | export const config = { 3 | "API_KEY": env === "development" ? "" : import.meta.env.VITE_APP_BOT_API_KEY, 4 | } -------------------------------------------------------------------------------- /src/utils/cn.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | server: { 7 | host: true, 8 | strictPort: true, 9 | port: 3000 10 | }, 11 | define: { 12 | global: 'window', 13 | }, 14 | base: './' 15 | }) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/context/SettingsContextDefinition.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export const SettingsContext = createContext(); 4 | 5 | export const useSettings = () => { 6 | const context = useContext(SettingsContext); 7 | if (!context) { 8 | throw new Error('useSettings must be used within a SettingsProvider'); 9 | } 10 | return context; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import Sidebar from './Sidebar'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | const Layout = () => { 5 | return ( 6 |
7 | 8 |
9 | 10 |
11 |
12 | ); 13 | }; 14 | 15 | export default Layout; 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; 2 | import { SettingsProvider } from './context/SettingsContext'; 3 | import Layout from './components/Layout'; 4 | import Settings from './components/Settings'; 5 | import ChatWithGemini from './components/ChatWithGemini'; 6 | import './App.css'; 7 | 8 | function App() { 9 | return ( 10 | 11 | 12 | 13 | }> 14 | } /> 15 | } /> 16 | } /> 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --bg-primary: #ffffff; 8 | --bg-secondary: #f4f4f5; 9 | --text-primary: #18181b; 10 | --text-secondary: #71717a; 11 | --border-color: #e4e4e7; 12 | } 13 | 14 | html[data-theme='dark'] { 15 | --bg-primary: #18181b; 16 | /* Zinc 900 */ 17 | --bg-secondary: #27272a; 18 | /* Zinc 800 */ 19 | --text-primary: #f4f4f5; 20 | --text-secondary: #a1a1aa; 21 | --border-color: #3f3f46; 22 | } 23 | 24 | html[data-theme='midnight'] { 25 | --bg-primary: #000000; 26 | /* Black */ 27 | --bg-secondary: #09090b; 28 | /* Zinc 950 */ 29 | --text-primary: #e4e4e7; 30 | --text-secondary: #71717a; 31 | --border-color: #27272a; 32 | } 33 | 34 | body { 35 | @apply bg-primary text-primary transition-colors duration-300; 36 | } 37 | } -------------------------------------------------------------------------------- /src/icons/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Gemini Bot 9 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | /* eslint-disable no-undef */ 3 | export default { 4 | darkMode: 'class', 5 | content: [ 6 | "./index.html", 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | primary: 'var(--bg-primary)', 13 | secondary: 'var(--bg-secondary)', 14 | 'text-primary': 'var(--text-primary)', 15 | 'text-secondary': 'var(--text-secondary)', 16 | 'border-color': 'var(--border-color)', 17 | }, 18 | backgroundColor: { 19 | primary: 'var(--bg-primary)', 20 | secondary: 'var(--bg-secondary)', 21 | }, 22 | textColor: { 23 | primary: 'var(--text-primary)', 24 | secondary: 'var(--text-secondary)', 25 | }, 26 | borderColor: { 27 | DEFAULT: 'var(--border-color)', 28 | } 29 | }, 30 | }, 31 | plugins: [ 32 | require('@tailwindcss/typography'), 33 | ], 34 | } 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sai Barath 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/service/gemini.service.js: -------------------------------------------------------------------------------- 1 | import { GoogleGenerativeAI } from "@google/generative-ai"; 2 | 3 | const GeminiService = { 4 | sendMessages: async function (message, history, apiKey, modelName) { 5 | if (!apiKey) { 6 | throw new Error("API Key is required"); 7 | } 8 | 9 | const genAI = new GoogleGenerativeAI(apiKey); 10 | const model = genAI.getGenerativeModel({ model: modelName }); 11 | 12 | const chat = model.startChat({ 13 | history: history, 14 | }); 15 | 16 | const makeRequest = async (retries = 3, delay = 1000) => { 17 | try { 18 | const result = await chat.sendMessageStream(message); 19 | return result.stream; 20 | } catch (error) { 21 | if ((error.status === 503 || error.message.includes('503')) && retries > 0) { 22 | console.log(`Model overloaded, retrying in ${delay}ms...`); 23 | await new Promise(resolve => setTimeout(resolve, delay)); 24 | return makeRequest(retries - 1, delay * 2); 25 | } 26 | throw error; 27 | } 28 | }; 29 | 30 | return makeRequest(); 31 | } 32 | }; 33 | 34 | export default GeminiService; 35 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: 'Roboto', sans-serif; 3 | height: 100vh; 4 | width: 100%; 5 | max-height: none !important; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | /* ScrollBar CSS */ 11 | 12 | ::-webkit-scrollbar-track { 13 | background-color: var(--bg-secondary); 14 | border-radius: 5px; 15 | } 16 | 17 | ::-webkit-scrollbar { 18 | width: 6px; 19 | height: 6px; 20 | background-color: var(--bg-primary); 21 | border-radius: 5px; 22 | } 23 | 24 | ::-webkit-scrollbar-thumb { 25 | background-color: var(--border-color); 26 | border-radius: 5px; 27 | } 28 | 29 | ::-webkit-scrollbar-thumb:hover { 30 | background-color: var(--text-secondary); 31 | } 32 | 33 | /* Loader */ 34 | 35 | .dot { 36 | width: 8px; 37 | height: 8px; 38 | border-radius: 50%; 39 | display: inline-block; 40 | animation: dot-keyframes 1.4s infinite; 41 | } 42 | 43 | .dot:nth-child(1) { 44 | animation-delay: 0.2s; 45 | } 46 | 47 | .dot:nth-child(2) { 48 | animation-delay: 0.4s; 49 | } 50 | 51 | .dot:nth-child(3) { 52 | animation-delay: 0.6s; 53 | } 54 | 55 | @keyframes dot-keyframes { 56 | 57 | 0%, 58 | 80%, 59 | 100% { 60 | transform: scale(0); 61 | } 62 | 63 | 40% { 64 | transform: scale(1); 65 | } 66 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemini-bot-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "homepage": "https://saibarathr.github.io/gemini-bot-react/", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@google/generative-ai": "^0.24.1", 15 | "clsx": "^2.1.1", 16 | "framer-motion": "^12.23.24", 17 | "lucide-react": "^0.554.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-markdown": "^9.0.1", 21 | "react-router-dom": "^7.9.6", 22 | "react-syntax-highlighter": "^16.1.0", 23 | "remark-gfm": "^4.0.1", 24 | "tailwind-merge": "^3.4.0", 25 | "uuid": "^13.0.0" 26 | }, 27 | "devDependencies": { 28 | "@tailwindcss/typography": "^0.5.19", 29 | "@types/react": "^18.2.43", 30 | "@types/react-dom": "^18.2.17", 31 | "@vitejs/plugin-react": "^4.2.1", 32 | "autoprefixer": "^10.4.16", 33 | "eslint": "^8.55.0", 34 | "eslint-plugin-react": "^7.33.2", 35 | "eslint-plugin-react-hooks": "^4.6.0", 36 | "eslint-plugin-react-refresh": "^0.4.5", 37 | "postcss": "^8.4.32", 38 | "tailwindcss": "^3.3.6", 39 | "vite": "^5.0.8" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 21.2.0 37 | cache: 'npm' 38 | - name: Install dependencies 39 | run: npm install 40 | - name: Build 41 | run: npm run build 42 | env: 43 | VITE_APP_BOT_API_KEY: ${{ secrets.VITE_APP_BOT_API_KEY }} 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v4 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | # Upload dist repository 50 | path: './dist' 51 | - name: Deploy to GitHub Pages 52 | id: deployment 53 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Gemini Bot 3 | 4 | ## Description 5 | 6 | This project is an AI chatbot that uses the Gemini API from Google. It's designed for educational purposes, providing a practical example of how to implement a chat bot using Gemini API. 7 | 8 | The chatbot has the following features: 9 | 10 | - **Multi-Conversation**: The chatbot can handle multiple conversations at once. It initializes the chat by calling `startChat()`, and then uses `sendMessage()` to send new user messages. These messages, along with the chatbot's responses, are appended to the chat history. 11 | 12 | - **User and Model Roles**: The chatbot uses two roles: 'user' and 'model'. The 'user' role provides the prompts, while the 'model' role provides the responses. 13 | 14 | - **Streaming**: The chatbot uses streaming for faster interactions. Instead of waiting for the model to complete the entire generation process, the chatbot can handle partial results for quicker responses. 15 | 16 | ## Screenshots 17 | 18 | ![Screenshot 2023-12-31 at 6 41 27 PM](https://github.com/SaiBarathR/gemini-bot-react/assets/58382813/fe6aa8e8-40bb-468a-bb21-2a8697c195ba) 19 | 20 | 21 | ## Dependencies 22 | 23 | This project uses the following libraries: 24 | 25 | - React 26 | - Vite 27 | - Tailwind 28 | - Chakra UI 29 | - React Markdown 30 | - @google/generative-ai 31 | 32 | ## API Key 33 | 34 | To run this project, you'll need an API key from Google. You can get one for free at [Google AI](https://ai.google.dev/). The free API key comes with some limitations: 35 | 36 | - Rate Limit: The free API key allows for up to 60 queries per minute. 37 | - Data Usage: The input/output data is used to improve Google's products. 38 | 39 | ## Usage 40 | 41 | To use this project: 42 | 43 | 1. Clone the repository. 44 | 2. Install the dependencies. 45 | 3. Insert your API key. 46 | 4. Run the project. 47 | 48 | ## License 49 | 50 | This project is free to use for educational purposes. 51 | 52 | ## Links 53 | 54 | - [Google AI](https://ai.google.dev/) 55 | - [Google AI Web QuickStart](https://ai.google.dev/tutorials/web_quickstart) 56 | -------------------------------------------------------------------------------- /src/hooks/useGemini.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import GeminiService from "../service/gemini.service"; 3 | import { useSettings } from "../context/SettingsContext"; 4 | 5 | export default function useGemini() { 6 | const { 7 | activeChatId, 8 | chats, 9 | updateChatMessages, 10 | getApiKeyForModel, 11 | createChat 12 | } = useSettings(); 13 | 14 | const [loading, setLoading] = useState(false); 15 | 16 | // Get current chat's messages 17 | const activeChat = chats.find(c => c.id === activeChatId); 18 | const messages = activeChat ? activeChat.messages : []; 19 | 20 | const sendMessages = async (text) => { 21 | let currentChatId = activeChatId; 22 | 23 | if (!currentChatId) { 24 | currentChatId = createChat(); 25 | } 26 | 27 | const currentChat = chats.find(c => c.id === currentChatId) || { 28 | id: currentChatId, 29 | modelId: 'gemini-2.5-flash', 30 | messages: [] 31 | }; 32 | 33 | const apiKey = getApiKeyForModel(currentChat.modelId); 34 | 35 | // Optimistic update: Add user message only 36 | // If it's a new chat, messages might be empty, so we use the found chat or default empty array 37 | const currentMessages = currentChat.messages || []; 38 | const newHistory = [...currentMessages, { "role": "user", "parts": [{ "text": text }] }]; 39 | 40 | updateChatMessages(currentChatId, newHistory); 41 | 42 | setLoading(true); 43 | 44 | // Local copy to track updates during streaming 45 | let currentHistory = [...newHistory]; 46 | 47 | try { 48 | if (!apiKey) { 49 | throw new Error(`Please configure API Key for ${currentChat.modelId} in Settings.`); 50 | } 51 | 52 | const apiHistory = newHistory.map(m => ({ 53 | role: m.role, 54 | parts: m.parts 55 | })); 56 | 57 | const stream = await GeminiService.sendMessages(text, apiHistory, apiKey, currentChat.modelId); 58 | 59 | let isFirstChunk = true; 60 | 61 | for await (const chunk of stream) { 62 | let chunkText = ''; 63 | try { 64 | chunkText = chunk.text(); 65 | } catch (e) { 66 | console.warn("Failed to get text from chunk", e); 67 | continue; // Skip chunks without text (e.g. safety blocks) 68 | } 69 | 70 | if (isFirstChunk) { 71 | isFirstChunk = false; 72 | setLoading(false); // Stop "Thinking..." animation 73 | 74 | // Add the model message with the first chunk 75 | currentHistory = [...currentHistory, { "role": "model", "parts": [{ "text": chunkText }] }]; 76 | } else { 77 | // Append to the last message 78 | const lastMsgIndex = currentHistory.length - 1; 79 | // We need to clone the message to avoid direct mutation of state objects if they were from state 80 | // But here currentHistory is a new array, and we are pushing new objects. 81 | // However, the previous objects are refs. 82 | // The last object was just created in the `if` block or previous iteration, so it's safe to mutate its parts. 83 | currentHistory[lastMsgIndex].parts[0].text += chunkText; 84 | } 85 | 86 | // Update global state 87 | updateChatMessages(currentChatId, [...currentHistory]); 88 | } 89 | } catch (error) { 90 | console.error('An error occurred:', error); 91 | setLoading(false); 92 | 93 | const lastMsg = currentHistory[currentHistory.length - 1]; 94 | 95 | // If error happened mid-stream (last msg is model), append error 96 | if (lastMsg.role === 'model') { 97 | lastMsg.parts[0].text += `\n\n[Error: ${error.message}]`; 98 | } else { 99 | // If error happened before stream (last msg is user), add error message 100 | currentHistory = [...currentHistory, { "role": "model", "parts": [{ "text": `Error: ${error.message || "Something went wrong."}` }] }]; 101 | } 102 | updateChatMessages(currentChatId, [...currentHistory]); 103 | } finally { 104 | setLoading(false); 105 | } 106 | } 107 | 108 | const clearMessages = () => { 109 | if (activeChatId) { 110 | updateChatMessages(activeChatId, []); 111 | } 112 | } 113 | 114 | return { messages, loading, sendMessages, clearMessages } 115 | } 116 | -------------------------------------------------------------------------------- /src/components/Settings.jsx: -------------------------------------------------------------------------------- 1 | import { useSettings } from '../context/SettingsContext'; 2 | import { Key, Cpu, Info, ArrowLeft } from 'lucide-react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | const Settings = () => { 6 | const { 7 | modelConfigs, 8 | setModelConfigs, 9 | availableModels 10 | } = useSettings(); 11 | 12 | const handleApiKeyChange = (modelId, value) => { 13 | setModelConfigs(prev => ({ 14 | ...prev, 15 | [modelId]: value 16 | })); 17 | }; 18 | 19 | return ( 20 |
21 |
22 |
23 | 24 | 25 | 26 |
27 |

Settings

28 |

29 | Configure your API keys and preferences. 30 |

31 |
32 |
33 | 34 |
35 | {/* API Key Section */} 36 |
37 |
38 |
39 | 40 |
41 |
42 |

Model Configuration

43 |

44 | Enter API keys for each model you wish to use. 45 |

46 |
47 |
48 | 49 |
50 | {availableModels.map(model => ( 51 |
52 |
53 | 57 | 58 | {model.id} 59 | 60 |
61 | handleApiKeyChange(model.id, e.target.value)} 65 | placeholder={`API Key for ${model.name}`} 66 | className="w-full px-4 py-2.5 rounded-lg border border-border-color bg-secondary text-text-primary placeholder:text-text-secondary focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500 outline-none transition-all text-sm" 67 | /> 68 |
69 | ))} 70 | 71 |
72 | 73 |

74 | Get your API keys from Google AI Studio. 75 | Keys are stored locally in your browser. 76 |

77 |
78 |
79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default Settings; 87 | -------------------------------------------------------------------------------- /src/context/SettingsContext.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { config } from '../utils/config'; 3 | import { SettingsContext } from './SettingsContextDefinition'; 4 | import PropTypes from 'prop-types'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | export { useSettings } from './SettingsContextDefinition'; 8 | 9 | export const SettingsProvider = ({ children }) => { 10 | // --- Model Configuration --- 11 | // modelConfigs: { [modelId]: apiKey } 12 | const [modelConfigs, setModelConfigs] = useState(() => { 13 | const saved = localStorage.getItem('gemini_model_configs'); 14 | return saved ? JSON.parse(saved) : {}; 15 | }); 16 | 17 | const [defaultApiKey, setDefaultApiKey] = useState(() => { 18 | return localStorage.getItem('gemini_api_key') || config.API_KEY || ''; 19 | }); 20 | 21 | const [model, setModel] = useState(() => { 22 | return localStorage.getItem('gemini_model') || 'gemini-2.5-flash'; 23 | }); 24 | 25 | // --- Chat Management --- 26 | const [chats, setChats] = useState(() => { 27 | const saved = localStorage.getItem('gemini_chats'); 28 | if (saved) return JSON.parse(saved); 29 | 30 | // Migration: Check for old 'messages' 31 | const oldMessages = localStorage.getItem('messages'); 32 | if (oldMessages) { 33 | const parsed = JSON.parse(oldMessages); 34 | if (parsed.length > 0) { 35 | const newChat = { 36 | id: uuidv4(), 37 | title: 'Migrated Chat', 38 | messages: parsed, 39 | modelId: 'gemini-2.5-flash', 40 | pinned: false, 41 | updatedAt: Date.now() 42 | }; 43 | return [newChat]; 44 | } 45 | } 46 | return []; 47 | }); 48 | 49 | const [activeChatId, setActiveChatId] = useState(() => { 50 | return localStorage.getItem('gemini_active_chat_id') || null; 51 | }); 52 | 53 | const [theme, setTheme] = useState(() => { 54 | return localStorage.getItem('theme') || 'dark'; 55 | }); 56 | 57 | // --- Effects for Persistence --- 58 | useEffect(() => { 59 | localStorage.setItem('gemini_model_configs', JSON.stringify(modelConfigs)); 60 | }, [modelConfigs]); 61 | 62 | useEffect(() => { 63 | if (defaultApiKey) { 64 | localStorage.setItem('gemini_api_key', defaultApiKey); 65 | } else { 66 | localStorage.removeItem('gemini_api_key'); 67 | } 68 | }, [defaultApiKey]); 69 | 70 | useEffect(() => { 71 | localStorage.setItem('gemini_model', model); 72 | }, [model]); 73 | 74 | useEffect(() => { 75 | localStorage.setItem('gemini_chats', JSON.stringify(chats)); 76 | }, [chats]); 77 | 78 | useEffect(() => { 79 | if (activeChatId) { 80 | localStorage.setItem('gemini_active_chat_id', activeChatId); 81 | } else { 82 | localStorage.removeItem('gemini_active_chat_id'); 83 | } 84 | }, [activeChatId]); 85 | 86 | useEffect(() => { 87 | localStorage.setItem('theme', theme); 88 | const root = document.documentElement; 89 | 90 | // Remove all potential theme classes/attributes first 91 | root.classList.remove('dark'); 92 | root.setAttribute('data-theme', theme); 93 | 94 | if (theme === 'dark' || theme === 'midnight') { 95 | root.classList.add('dark'); 96 | } 97 | }, [theme]); 98 | 99 | // --- Helper Functions --- 100 | 101 | const getApiKeyForModel = (modelId) => { 102 | return modelConfigs[modelId] || defaultApiKey; 103 | }; 104 | 105 | const createChat = (modelId = model) => { 106 | const newChat = { 107 | id: uuidv4(), 108 | title: 'New Chat', 109 | messages: [], 110 | modelId: modelId, 111 | pinned: false, 112 | updatedAt: Date.now() 113 | }; 114 | setChats(prev => [newChat, ...prev]); 115 | setActiveChatId(newChat.id); 116 | return newChat.id; 117 | }; 118 | 119 | const deleteChat = (chatId) => { 120 | setChats(prev => prev.filter(c => c.id !== chatId)); 121 | if (activeChatId === chatId) { 122 | setActiveChatId(null); 123 | } 124 | }; 125 | 126 | const pinChat = (chatId) => { 127 | setChats(prev => prev.map(c => 128 | c.id === chatId ? { ...c, pinned: !c.pinned } : c 129 | )); 130 | }; 131 | 132 | const updateChat = (chatId, updates) => { 133 | setChats(prev => prev.map(c => 134 | c.id === chatId ? { ...c, ...updates, updatedAt: Date.now() } : c 135 | )); 136 | }; 137 | 138 | const updateChatMessages = (chatId, newMessages) => { 139 | setChats(prev => prev.map(c => { 140 | if (c.id === chatId) { 141 | // Auto-generate title from first user message if title is "New Chat" 142 | let title = c.title; 143 | if (c.title === 'New Chat' && newMessages.length > 0) { 144 | const firstUserMsg = newMessages.find(m => m.role === 'user'); 145 | if (firstUserMsg) { 146 | title = firstUserMsg.parts[0].text.slice(0, 30) + (firstUserMsg.parts[0].text.length > 30 ? '...' : ''); 147 | } 148 | } 149 | return { ...c, messages: newMessages, title, updatedAt: Date.now() }; 150 | } 151 | return c; 152 | })); 153 | }; 154 | 155 | // Ensure there's always an active chat if chats exist but none selected 156 | useEffect(() => { 157 | if (chats.length > 0 && !activeChatId) { 158 | setActiveChatId(chats[0].id); 159 | } else if (chats.length === 0 && !activeChatId) { 160 | // Don't auto-create here to avoid loops, handle in UI or on interaction 161 | } 162 | }, [chats, activeChatId]); 163 | 164 | const value = { 165 | // Config 166 | apiKey: defaultApiKey, // Deprecated access, prefer getApiKeyForModel 167 | setApiKey: setDefaultApiKey, 168 | modelConfigs, 169 | setModelConfigs, 170 | getApiKeyForModel, 171 | 172 | // Models 173 | model, 174 | setModel, 175 | availableModels: [ 176 | { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, 177 | { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, 178 | ], 179 | 180 | // Chats 181 | chats, 182 | activeChatId, 183 | setActiveChatId, 184 | createChat, 185 | deleteChat, 186 | pinChat, 187 | updateChat, 188 | updateChatMessages, 189 | 190 | // Theme 191 | theme, 192 | setTheme, 193 | }; 194 | 195 | return ( 196 | 197 | {children} 198 | 199 | ); 200 | }; 201 | 202 | SettingsProvider.propTypes = { 203 | children: PropTypes.node.isRequired, 204 | }; 205 | -------------------------------------------------------------------------------- /src/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import { MessageSquare, Settings, Moon, Sun, Github, Plus, Pin, Trash2, MoreVertical, PanelLeftClose, PanelLeft } from 'lucide-react'; 2 | import PropTypes from 'prop-types'; 3 | import { useSettings } from '../context/SettingsContext'; 4 | import { cn } from '../utils/cn'; 5 | import { Link, useLocation, useNavigate } from 'react-router-dom'; 6 | import { useState, useRef, useEffect } from 'react'; 7 | 8 | const Sidebar = () => { 9 | const { theme, setTheme, chats, activeChatId, setActiveChatId, createChat, deleteChat, pinChat } = useSettings(); 10 | const location = useLocation(); 11 | const navigate = useNavigate(); 12 | const [menuOpenId, setMenuOpenId] = useState(null); 13 | const [isCollapsed, setIsCollapsed] = useState(false); 14 | const menuRef = useRef(null); 15 | 16 | const toggleTheme = () => { 17 | if (theme === 'light') setTheme('dark'); 18 | else if (theme === 'dark') setTheme('midnight'); 19 | else setTheme('dark'); 20 | }; 21 | 22 | const getThemeIcon = () => { 23 | if (theme === 'light') return ; 24 | if (theme === 'dark') return ; 25 | return ; // Filled moon for midnight 26 | }; 27 | 28 | const getThemeLabel = () => { 29 | if (theme === 'light') return 'Light Mode'; 30 | if (theme === 'dark') return 'Dark Mode'; 31 | return 'Midnight Mode'; 32 | }; 33 | 34 | const handleNewChat = () => { 35 | createChat(); 36 | if (location.pathname !== '/') { 37 | navigate('/'); 38 | } 39 | }; 40 | 41 | const handleChatClick = (chatId) => { 42 | setActiveChatId(chatId); 43 | if (location.pathname !== '/') { 44 | navigate('/'); 45 | } 46 | }; 47 | 48 | // Close menu when clicking outside 49 | useEffect(() => { 50 | const handleClickOutside = (event) => { 51 | if (menuRef.current && !menuRef.current.contains(event.target)) { 52 | setMenuOpenId(null); 53 | } 54 | }; 55 | document.addEventListener('mousedown', handleClickOutside); 56 | return () => document.removeEventListener('mousedown', handleClickOutside); 57 | }, []); 58 | 59 | const ChatItem = ({ chat }) => { 60 | const isActive = activeChatId === chat.id && location.pathname === '/'; 61 | 62 | return ( 63 |
handleChatClick(chat.id)} 70 | > 71 | 72 | {!isCollapsed && ( 73 | <> 74 | {chat.title} 75 | 76 | {chat.pinned && } 77 | 78 | {/* Action Menu Trigger */} 79 | 91 | 92 | )} 93 | 94 | {/* Dropdown Menu */} 95 | {menuOpenId === chat.id && !isCollapsed && ( 96 |
e.stopPropagation()} 100 | > 101 | 111 | 121 |
122 | )} 123 |
124 | ); 125 | }; 126 | 127 | ChatItem.propTypes = { 128 | chat: PropTypes.object.isRequired, 129 | }; 130 | 131 | const pinnedChats = chats.filter(c => c.pinned); 132 | const recentChats = chats.filter(c => !c.pinned); 133 | 134 | return ( 135 |
139 |
140 | {!isCollapsed && ( 141 |

142 | Gemini 143 |

144 | )} 145 | 151 |
152 | 153 |
154 | 164 |
165 | 166 |
167 | {/* Pinned Chats */} 168 | {pinnedChats.length > 0 && ( 169 |
170 | {!isCollapsed && ( 171 |

172 | Pinned 173 |

174 | )} 175 | {pinnedChats.map(chat => ( 176 | 177 | ))} 178 |
179 | )} 180 | 181 | {/* Recent Chats */} 182 |
183 | {!isCollapsed && ( 184 |

185 | Recent 186 |

187 | )} 188 | {recentChats.length === 0 && pinnedChats.length === 0 ? ( 189 | !isCollapsed &&
No chats yet
190 | ) : ( 191 | recentChats.map(chat => ( 192 | 193 | )) 194 | )} 195 |
196 |
197 | 198 |
199 | 210 | 211 | {!isCollapsed && Settings} 212 | 213 | 214 | 229 | 230 | 240 | 241 | {!isCollapsed && GitHub} 242 | 243 |
244 |
245 | ); 246 | }; 247 | 248 | export default Sidebar; 249 | -------------------------------------------------------------------------------- /src/components/ChatWithGemini.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Send, Bot, Loader2, ChevronDown, Copy, Check, Sparkles, Code, BookOpen, Lightbulb } from 'lucide-react'; 3 | import ReactMarkdown from 'react-markdown'; 4 | import remarkGfm from 'remark-gfm'; 5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 6 | import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; 7 | import { motion, AnimatePresence } from 'framer-motion'; 8 | import useGemini from "../hooks/useGemini"; 9 | import { useSettings } from "../context/SettingsContext"; 10 | import { cn } from "../utils/cn"; 11 | 12 | const ChatWithGemini = () => { 13 | const { messages, loading, sendMessages } = useGemini(); 14 | const { activeChatId, chats, updateChat, availableModels } = useSettings(); 15 | const [input, setInput] = useState(''); 16 | const scrollRef = useRef(null); 17 | const textareaRef = useRef(null); 18 | const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); 19 | const modelDropdownRef = useRef(null); 20 | 21 | // Close dropdown when clicking outside 22 | useEffect(() => { 23 | const handleClickOutside = (event) => { 24 | if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target)) { 25 | setIsModelDropdownOpen(false); 26 | } 27 | }; 28 | document.addEventListener('mousedown', handleClickOutside); 29 | return () => document.removeEventListener('mousedown', handleClickOutside); 30 | }, []); 31 | 32 | const activeChat = chats.find(c => c.id === activeChatId); 33 | 34 | useEffect(() => { 35 | if (scrollRef.current) { 36 | scrollRef.current.scrollIntoView({ behavior: 'smooth' }); 37 | } 38 | }, [messages, loading]); 39 | 40 | // Auto-resize textarea 41 | useEffect(() => { 42 | if (textareaRef.current) { 43 | textareaRef.current.style.height = 'auto'; 44 | textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; 45 | } 46 | }, [input]); 47 | 48 | const handleSend = async (text = input) => { 49 | if (!text.trim()) return; 50 | setInput(''); 51 | if (textareaRef.current) textareaRef.current.style.height = 'auto'; 52 | await sendMessages(text); 53 | }; 54 | 55 | const handleKeyDown = (e) => { 56 | if (e.key === 'Enter' && !e.shiftKey) { 57 | e.preventDefault(); 58 | handleSend(); 59 | } 60 | }; 61 | 62 | const handleModelChange = (e) => { 63 | if (activeChatId) { 64 | updateChat(activeChatId, { modelId: e.target.value }); 65 | } 66 | }; 67 | 68 | const CodeBlock = ({ node, inline, className, children, ...props }) => { 69 | const match = /language-(\w+)/.exec(className || ''); 70 | const [copied, setCopied] = useState(false); 71 | 72 | const handleCopy = () => { 73 | navigator.clipboard.writeText(String(children).replace(/\n$/, '')); 74 | setCopied(true); 75 | setTimeout(() => setCopied(false), 2000); 76 | }; 77 | 78 | return !inline && match ? ( 79 |
80 |
81 | {match[1]} 82 | 89 |
90 | 97 | {String(children).replace(/\n$/, '')} 98 | 99 |
100 | ) : ( 101 | 102 | {children} 103 | 104 | ); 105 | }; 106 | 107 | const suggestions = [ 108 | { icon: , text: "Write a React component for a todo list" }, 109 | { icon: , text: "Explain quantum entanglement simply" }, 110 | { icon: , text: "Generate creative blog post titles" }, 111 | { icon: , text: "Tips for improving productivity" }, 112 | ]; 113 | 114 | if (!activeChatId && chats.length === 0 || messages.length === 0) { 115 | return ( 116 |
117 | {/* Chat Header */} 118 |
119 |
120 |

121 | {activeChat?.title || 'New Chat'} 122 |

123 |
124 | 125 |
126 | 133 | 134 | 135 | {isModelDropdownOpen && ( 136 | 143 | {availableModels.map(m => ( 144 | 164 | ))} 165 | 166 | )} 167 | 168 |
169 |
170 | 171 |
172 |
173 |
174 |
175 | 176 |
177 |

178 | How can I help you today? 179 |

180 |
181 | 182 |
183 | {suggestions.map((s, i) => ( 184 | 196 | ))} 197 |
198 |
199 |
200 | 201 | {/* Input Area for Empty State */} 202 |
203 |
204 |
205 |