├── 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 | 
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 |
222 |
223 |
224 | Gemini can make mistakes. Consider checking important information.
225 |
226 |
227 |
228 |
229 |
230 | );
231 | }
232 |
233 | return (
234 |
235 | {/* Chat Header */}
236 |
237 |
238 |
239 | {activeChat?.title || 'New Chat'}
240 |
241 |
242 |
243 |
244 |
253 |
254 |
255 |
256 |
257 | {/* Messages Area */}
258 |
259 |
260 |
261 | {messages.map((msg, idx) => (
262 |
271 | {msg.role === 'model' && (
272 |
273 |
274 |
275 | )}
276 |
277 |
283 |
(
289 |
292 | ),
293 | thead: ({ node, ...props }) => (
294 |
295 | ),
296 | th: ({ node, ...props }) => (
297 | |
298 | ),
299 | td: ({ node, ...props }) => (
300 | |
301 | ),
302 | ul: ({ node, ...props }) => (
303 |
304 | ),
305 | ol: ({ node, ...props }) => (
306 |
307 | ),
308 | li: ({ node, ...props }) => (
309 |
310 | ),
311 | a: ({ node, ...props }) => (
312 |
313 | ),
314 | blockquote: ({ node, ...props }) => (
315 |
316 | ),
317 | h1: ({ node, ...props }) => ,
318 | h2: ({ node, ...props }) => ,
319 | h3: ({ node, ...props }) => ,
320 | }}
321 | >
322 | {msg.parts[0].text}
323 |
324 |
325 |
326 | ))}
327 |
328 |
329 | {loading && (
330 |
335 |
336 |
337 |
338 |
339 |
340 | Thinking...
341 |
342 |
343 | )}
344 |
345 |
346 |
347 |
348 | {/* Input Area */}
349 |
350 |
351 |
352 |
369 |
370 |
371 | Gemini can make mistakes. Consider checking important information.
372 |
373 |
374 |
375 |
376 |
377 | );
378 | };
379 |
380 | export default ChatWithGemini;
--------------------------------------------------------------------------------