diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..73e005c2 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 00000000..d239dc23 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,56 @@ +name: Docker Build & Deploy to Vercel + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + # Step A: Pull Vercel Config + # We do this OUTSIDE Docker first so we have the valid .vercel folder + # to copy INTO the Docker container. + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + # Step B: Build the Docker Image + # Automates: docker build --build-arg VERCEL_TOKEN=... -t overlap-chatgpt . + - name: Build Docker Image + run: | + docker build \ + --build-arg VERCEL_TOKEN=${{ secrets.VERCEL_TOKEN }} \ + -t overlap-chatgpt . + + # Step C: Extract Artifacts + # Automates: docker create, docker cp, docker rm + - name: Extract Prebuilt Artifacts + run: | + # Create a temporary container (don't run it, just create it) + docker create --name temp_container overlap-chatgpt + + # Copy the .vercel output folder from the container to the runner + # Note: We overwrite the local .vercel folder with the build output + docker cp temp_container:/.vercel . + + # Cleanup + docker rm temp_container + + # Step D: Deploy to Vercel + # Automates: vercel deploy --prebuilt --prod + - name: Deploy to Vercel + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6769e21d..f66f6d63 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ +.vercel diff --git a/ClientServer.md b/ClientServer.md new file mode 100644 index 00000000..a69da583 --- /dev/null +++ b/ClientServer.md @@ -0,0 +1,150 @@ +Overlap-chatgptCloneThis is the backend server for the Overlap ChatGPT Clone, a Flask application designed to serve a chat API. It is configured for deployment on Vercel using a custom Docker build environment.🚀 How to Run LocallyFollow these steps to run the server on your local machine for development.1. PrerequisitesPython 3.12A Python virtual environment (recommended)A config.json file (see below)2. Local InstallationClone the repository:git clone [https://github.com/KennyAngelikas/Overlap-chatgptClone] +cd Overlap-chatgptClone +Create and activate a virtual environment:python3 -m venv venv +source venv/bin/activate +Install the required Python packages:pip install -r requirements.txt +Create your configuration file. Your app reads settings from config.json. Create this file in the root directory.{ + "site_config": { + "host": "0.0.0.0", + "port": 1338, + "debug": true + }, + "database": { + "url": "postgresql://user:password@localhost:5432/mydb" + }, + "api_keys": { + "gemini": "YOUR_GEMINI_API_KEY_HERE" + } +} +Run the application:python run.py +Your server should now be running on http://localhost:1338.📦 How to Deploy to VercelThis project is deployed using a prebuilt output from a custom Docker container. This complex process is required to build psycopg2 correctly for Vercel's Amazon Linux runtime.1. PrerequisitesDocker Desktop must be installed and running.Vercel CLI must be installed: npm install -g vercelA Vercel account.2. Required Project FilesYou must have these four files in your project's root directory.DockerfileThis file builds your project inside an environment identical to Vercel's (Amazon Linux 2023).# Stage 1: The "builder" +# USE THE OFFICIAL AWS LAMBDA PYTHON 3.12 IMAGE (Amazon Linux 2023) +FROM public.ecr.aws/lambda/python:3.12 AS builder + +WORKDIR /app + +# Install build tools, node, and npm using DNF +RUN dnf update -y && dnf install -y "Development Tools" nodejs npm + +# 2. Install Python dependencies +COPY requirements.txt requirements.txt +RUN pip3 install --user --no-cache-dir -r requirements.txt +# Add Python's user bin to the PATH +ENV PATH=/root/.local/bin:$PATH + +# 3. Install Vercel CLI +RUN npm install --global vercel@latest + +# 4. Copy all your project files +COPY . . + +# 5. Copy your Vercel project link +COPY .vercel .vercel + +# 6. Build the project using Vercel CLI +ARG VERCEL_TOKEN +RUN VERCEL_TOKEN=$VERCEL_TOKEN vercel build --prod + +# --- +# Stage 2: The "final output" +FROM alpine:latest + +# Copy the entire .vercel folder +COPY --from=builder /app/.vercel /.vercel +vercel.jsonThis file tells Vercel how to build and route your Python app.{ + "builds": [ + { + "src": "run.py", + "use": "@vercel/python", + "config": { "pythonVersion": "3.12" } + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "run.py" + } + ] +} +requirements.txtMake sure this file uses psycopg2-binary.flask +python-dotenv +requests +beautifulsoup4 +psycopg2-binary +# ... any other libraries +.dockerignoreThis speeds up your Docker build by ignoring unnecessary files.# Venv +venv/ + +# Docker build output +.vercel + +# Python cache +__pycache__/ +*.pyc +3. ⚠️ Important: Fix config.json for VercelYour run.py script (which reads config.json) will fail on Vercel. Vercel uses Environment Variables for secrets, not JSON files.You must modify your run.py to read from os.environ.Original run.py (Local only):# ... +from json import load + +if __name__ == '__main__': + config = load(open('config.json', 'r')) + site_config = config['site_config'] + # ... +Modified run.py (Works locally AND on Vercel):from server.app import app +from server.website import Website +from server.controller.conversation_controller import ConversationController +from json import load +import os # Import os + +# --- VERCEL FIX --- +# Check if running on Vercel (or any system with ENV VARS) +db_url = os.environ.get('DATABASE_URL') +site_port = os.environ.get('PORT', 1338) # Vercel provides a PORT + +if db_url: + # We are on Vercel or similar + site_config = { + "host": "0.0.0.0", + "port": int(site_port), + "debug": False + } + # You would also load other configs (like GEMINI_API_KEY) here + # os.environ.get('GEMINI_API_KEY') +else: + # We are local, load from config.json + config = load(open('config.json', 'r')) + site_config = config['site_config'] + # You would also load DB URL from config here + # db_url = config['database']['url'] +# --- END FIX --- + + +# This logic is now outside the __name__ block +site = Website(app) +for route in site.routes: + app.add_url_rule( + route, + view_func = site.routes[route]['function'], + methods = site.routes[route]['methods'], + ) + +ConversationController(app) + +# This will run for a 404 +@app.route('/', methods=['GET']) +def handle_root(): + return "Flask server is running!" + +# This block is for local development only +if __name__ == '__main__': + print(f"Running on port {site_config['port']}") + app.run(**site_config) + print(f"Closing port {site_config['port']}") +4. Deployment StepsStep 1: One-Time Vercel SetupLog in to Vercel CLI:vercel login +Link your project:vercel link +Pull project settings:vercel pull --yes +Add Vercel Environment Variables:Go to your project's dashboard on Vercel.Go to Settings > Environment Variables.Add all your secrets (e.g., DATABASE_URL, GEMINI_API_KEY). These must match the os.environ.get() keys in your run.py.Step 2: The 6-Step Deploy ProcessRun these commands from your project's root directory every time you want to deploy a change.Build the Docker image: (This will take a few minutes)docker build --build-arg VERCEL_TOKEN="YOUR_VERCEL_TOKEN_HERE" -t overlap-chatgpt . +(Get your token from Vercel Dashboard > Settings > Tokens)Remove the old container (in case it exists):docker rm temp_container +Create a new container from the image:docker create --name temp_container overlap-chatgpt +Copy the build output from the container to your computer:docker cp temp_container:/.vercel . +Clean up the container:docker rm temp_container +Deploy the prebuilt output!vercel deploy --prebuilt --prod +🔌 Architecture: Client-Server InteractionThis repository is a JSON API backend. It is only the "server" part of your application.Client (The "Browser")A user visits your Vercel URL (e.g., https://overlap-chatgpt-clone.vercel.app).Vercel serves your static frontend (e.g., React, HTML/JS) from the Website routes.The user types a message in the chat.Server (This Flask App)Your frontend's JavaScript makes an HTTP request (e.g., a POST request to /api/chat) with the user's message.Vercel routes this request to your run.py serverless function.The ConversationController receives the request.It calls services like gemini_service (to talk to an AI) and teams_service (to get data).The teams_service uses db_model to query your PostgreSQL database (using psycopg2).The services return data to the controller.ResponseThe ConversationController formats a JSON response.Flask sends this JSON back to the client.Your frontend's JavaScript receives the JSON and displays the chat message to the user. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ffd3f024..3637d339 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,34 @@ -# Build stage -FROM python:3.8-alpine AS build +# Stage 1: The "builder" +# USE THE OFFICIAL AWS LAMBDA PYTHON 3.12 IMAGE (Amazon Linux 2023) +FROM public.ecr.aws/lambda/python:3.12 AS builder WORKDIR /app +# CHANGED: Removed "Development Tools". We only need nodejs and npm. +RUN dnf update -y && dnf install -y nodejs npm + +# 2. Install Python dependencies COPY requirements.txt requirements.txt -RUN apk add --no-cache build-base && \ - pip3 install --user --no-cache-dir -r requirements.txt +RUN pip3 install --user --no-cache-dir -r requirements.txt +# Add Python's user bin to the PATH +ENV PATH=/root/.local/bin:$PATH +# 3. Install Vercel CLI +RUN npm install --global vercel@latest + +# 4. Copy all your project files COPY . . -# Production stage -FROM python:3.8-alpine AS production +# 5. Copy your Vercel project link +COPY .vercel .vercel -WORKDIR /app +# 6. Build the project using Vercel CLI +ARG VERCEL_TOKEN +RUN VERCEL_TOKEN=$VERCEL_TOKEN vercel build --prod -COPY --from=build /root/.local /root/.local -COPY . . - -ENV PATH=/root/.local/bin:$PATH +# --- +# Stage 2: The "final output" +FROM alpine:latest -CMD ["python3", "./run.py"] +# Copy the entire .vercel folder +COPY --from=builder /app/.vercel /.vercel \ No newline at end of file diff --git a/README.md b/README.md index 739cc900..d0fef7cf 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,33 @@ -Development of this repository is currently in a halt, due to lack of time. Updates are comming end of June. - -working again ; ) -I am very busy at the moment so I would be very thankful for contributions and PR's - -## To do -- [x] Double confirm when deleting conversation -- [x] remember user preferences -- [x] theme changer -- [ ] loading / exporting a conversation -- [ ] speech output and input (elevenlabs; ex: https://github.com/cogentapps/chat-with-gpt) -- [ ] load files, ex: https://github.com/mayooear/gpt4-pdf-chatbot-langchain -- [ ] better documentation -- [ ] use react / faster backend language ? (newbies may be more confused and discouraged to use it) - -# ChatGPT Clone -feel free to improve the code / suggest improvements - +# Project Overlap +UPDATE THIS IMAGE WITH AN EXAMPLE IMAGE image +## Overview +### Project Abstract +Overlap is a collaborative chatbot ecosystem designed to enhance peer-to-peer knowledge sharing within a team. Instead of acting as a generic Q&A assistant, the system connects teammates to one another based on overlapping interests, recent learning activity, or self-declared expertise. + +Each team member has a personal chatbot (e.g., in Slack). When a user asks their bot for help — for example, “Teach me React basics.” — the bot consults a shared, opt-in knowledge index to identify peers who either know or recently asked about React. If a match is found, the bot responds: “Bob has ‘React’ expertise, and Alice asked about React two days ago. Want to connect with them?” + +This replaces solitary AI help with socially intelligent nudges that build team relationships while maintaining individual privacy. Later phases will visualize shared learning across the team through a mind-map view, showing connections between topics and people (opt-in only). + +### Novelty +Why is this novel? Recent work shows that AI is eroding the social fabric inside and outside the classroom. Students are going to TAs, peers, and instructors at the lowest rates ever. That’s a problem because peer support is tightly linked to students’ wellbeing—and without it, students feel more isolated than ever. + +_**Our AI directly confronts this. Instead of replacing relationships, it is designed to build them.**_ + +A second piece of novelty is how we handle “expertise finding.” Academic teams struggle to know who to go to for help. Our AI reduces that cognitive load by matching the questions students ask with the knowledge and experience already present in their community, connecting them to the right expert and strengthening human relationships along the way. + + +# Testing +feel free to improve the code / suggest improvements + +## Client + Testing +To test our application, please go here --> https://overlap-chatgpt-clone-oah03fquq-manvender-singhs-projects.vercel.app/chat/ +``` +WARNING: PLEASE DO NOT PROMPT TOO MUCH OR IT WILL START CHARGING YOU +``` -## Getting Started +## Getting Started Development To get started with this project, you'll need to clone the repository and set up a virtual environment. This will allow you to install the required dependencies without affecting your system-wide Python installation. ### Prequisites @@ -28,7 +36,7 @@ Before you can set up a virtual environment, you'll need to have Python installe ### Cloning the Repository Run the following command to clone the repository: ``` -git clone https://github.com/xtekky/chatgpt-clone.git +git clone this repo ``` ### Setting up a Virtual Environment diff --git a/client/css/style.css b/client/css/style.css index a1f69087..a2d464b1 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -16,800 +16,1019 @@ } */ :root { - --colour-1: #000000; - --colour-2: #ccc; - --colour-3: #e4d4ff; - --colour-4: #f0f0f0; - --colour-5: #181818; - --colour-6: #242424; + --colour-1: #000000; + --colour-2: #ccc; + --colour-3: #e4d4ff; + --colour-4: #f0f0f0; + --colour-5: #181818; + --colour-6: #242424; - --accent: #8b3dff; - --blur-bg: #16101b66; - --blur-border: #84719040; - --user-input: #ac87bb; - --conversations: #c7a2ff; + --accent: #8b3dff; + --blur-bg: #16101b66; + --blur-border: #84719040; + --user-input: #ac87bb; + --conversations: #c7a2ff; } :root { - --font-1: "Inter", sans-serif; - --section-gap: 25px; - --border-radius-1: 8px; + --font-1: "Inter", sans-serif; + --section-gap: 25px; + --border-radius-1: 8px; } * { - margin: 0; - padding: 0; - box-sizing: border-box; - position: relative; - font-family: var(--font-1); + margin: 0; + padding: 0; + box-sizing: border-box; + position: relative; + font-family: var(--font-1); +} + +.bottom_buttons button { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + vertical-align: middle; + line-height: normal; } html, body { - scroll-behavior: smooth; - overflow: hidden; + scroll-behavior: smooth; + /* Keep the overall page from scrolling — the app will make the chat + messages area the only scrollable region. This keeps the input and + header always visible. */ + overflow: hidden; + height: 100%; } body { - padding: var(--section-gap); - background: var(--colour-1); - color: var(--colour-3); - min-height: 100vh; + padding: var(--section-gap); + background: var(--colour-1); + color: var(--colour-3); + min-height: 100vh; } .row { - display: flex; - gap: var(--section-gap); - height: 100%; + display: flex; + gap: var(--section-gap); + /* occupy the viewport (accounting for body padding) */ + height: calc(100vh - 2 * var(--section-gap)); } .box { - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - background-color: var(--blur-bg); - height: 100%; - width: 100%; - border-radius: var(--border-radius-1); - border: 1px solid var(--blur-border); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: var(--blur-bg); + height: 100%; + width: 100%; + border-radius: var(--border-radius-1); + border: 1px solid var(--blur-border); } .conversations { - max-width: 260px; - padding: var(--section-gap); - overflow: auto; - flex-shrink: 0; - display: flex; - flex-direction: column; - justify-content: space-between; + max-width: 260px; + padding: var(--section-gap); + /* keep the left sidebar static and visible; do not let the page scroll */ + overflow: visible; + /* make the sidebar a fixed column so it doesn't collapse to zero width */ + flex: 0 0 260px; + width: 260px; + display: flex; + flex-direction: column; + justify-content: space-between; } .conversation { - width: 100%; - min-height: 50%; - height: 100vh; - overflow-y: scroll; - overflow-x: hidden; - display: flex; - flex-direction: column; - gap: 15px; + width: 100%; + min-height: 50%; + /* Fill remaining vertical space inside .row. The messages area will + scroll independently; the input stays fixed at the bottom of this box. */ + /* allow the conversation column to grow and take remaining horizontal space */ + flex: 1 1 auto; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 15px; } .conversation #messages { - width: 100%; - display: flex; - flex-direction: column; - overflow-wrap: break-word; - overflow-y: inherit; - overflow-x: hidden; - padding-bottom: 50px; + width: 100%; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + /* messages area takes remaining space and scrolls */ + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: 12px; } .conversation .user-input { - max-height: 10vh; + /* keep input area fixed height so layout is predictable */ + flex: 0 0 auto; } .conversation .user-input input { - font-size: 15px; - width: 100%; - height: 100%; - padding: 12px 15px; - background: none; - border: none; - outline: none; - color: var(--colour-3); + font-size: 15px; + width: 100%; + height: 100%; + padding: 12px 15px; + background: none; + border: none; + outline: none; + color: var(--colour-3); } .conversation .user-input input::placeholder { - color: var(--user-input) + color: var(--user-input); } .gradient:nth-child(1) { - --top: 0; - --right: 0; - --size: 70vw; - --blur: calc(0.5 * var(--size)); - --opacity: 0.3; - animation: zoom_gradient 6s infinite; + --top: 0; + --right: 0; + --size: 70vw; + --blur: calc(0.5 * var(--size)); + --opacity: 0.3; + animation: zoom_gradient 6s infinite; } .gradient { - position: absolute; - z-index: -1; - border-radius: calc(0.5 * var(--size)); - background-color: var(--accent); - background: radial-gradient(circle at center, var(--accent), var(--accent)); - width: 70vw; - height: 70vw; - top: 50%; - right: 0; - transform: translateY(-50%); - filter: blur(calc(0.5 * 70vw)) opacity(var(--opacity)); + position: absolute; + z-index: -1; + border-radius: calc(0.5 * var(--size)); + background-color: var(--accent); + background: radial-gradient(circle at center, var(--accent), var(--accent)); + width: 70vw; + height: 70vw; + top: 50%; + right: 0; + transform: translateY(-50%); + filter: blur(calc(0.5 * 70vw)) opacity(var(--opacity)); } .conversations { - display: flex; - flex-direction: column; - gap: 16px; - flex: auto; - min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; + flex: auto; + min-width: 0; } .conversations .title { - font-size: 14px; - font-weight: 500; + font-size: 14px; + font-weight: 500; } .conversations .convo { - padding: 8px 12px; - display: flex; - gap: 18px; - align-items: center; - user-select: none; - justify-content: space-between; + padding: 8px 12px; + display: flex; + gap: 18px; + align-items: center; + user-select: none; + justify-content: space-between; } .conversations .convo .left { - cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - flex: auto; - min-width: 0; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + flex: auto; + min-width: 0; } .conversations i { - color: var(--conversations); - cursor: pointer; + color: var(--conversations); + cursor: pointer; } .convo-title { - color: var(--colour-3); - font-size: 14px; - overflow: hidden; - text-overflow: ellipsis; + color: var(--colour-3); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; } .message { - - width: 100%; - overflow-wrap: break-word; - display: flex; - gap: var(--section-gap); - padding: var(--section-gap); - padding-bottom: 0; + width: 100%; + overflow-wrap: break-word; + display: flex; + gap: var(--section-gap); + padding: var(--section-gap); + padding-bottom: 0; } .message:last-child { - animation: 0.6s show_message; + animation: 0.6s show_message; } @keyframes show_message { - from { - transform: translateY(10px); - opacity: 0; - } + from { + transform: translateY(10px); + opacity: 0; + } } .message .user { - max-width: 48px; - max-height: 48px; - flex-shrink: 0; + max-width: 48px; + max-height: 48px; + flex-shrink: 0; } .message .user img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 8px; - outline: 1px solid var(--blur-border); + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; + outline: 1px solid var(--blur-border); } .message .user:after { - content: "63"; - position: absolute; - bottom: 0; - right: 0; - height: 60%; - width: 60%; - background: var(--colour-3); - filter: blur(10px) opacity(0.5); - z-index: 10000; + content: "63"; + position: absolute; + bottom: 0; + right: 0; + height: 60%; + width: 60%; + background: var(--colour-3); + filter: blur(10px) opacity(0.5); + z-index: 10000; } .message .content { - display: flex; - flex-direction: column; - gap: 18px; - min-width: 0; + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; } .message .content p, .message .content li, .message .content code { - font-size: 15px; - line-height: 1.3; + font-size: 15px; + line-height: 1.3; } .message .user i { - position: absolute; - bottom: -6px; - right: -6px; - z-index: 1000; + position: absolute; + bottom: -6px; + right: -6px; + z-index: 1000; } .new_convo { - padding: 8px 12px; - display: flex; - gap: 18px; - align-items: center; - cursor: pointer; - user-select: none; - background: transparent; - border: 1px dashed var(--conversations); - border-radius: var(--border-radius-1); + padding: 8px 12px; + display: flex; + gap: 18px; + align-items: center; + cursor: pointer; + user-select: none; + background: transparent; + border: 1px dashed var(--conversations); + border-radius: var(--border-radius-1); } .new_convo span { - color: var(--colour-3); - font-size: 14px; + color: var(--colour-3); + font-size: 14px; } .new_convo:hover { - border-style: solid; + border-style: solid; } .stop_generating { - position: absolute; - bottom: 118px; - /* left: 10px; + position: absolute; + bottom: 118px; + /* left: 10px; bottom: 125px; right: 8px; */ - left: 50%; - transform: translateX(-50%); - z-index: 1000000; + left: 50%; + transform: translateX(-50%); + z-index: 1000000; } .stop_generating button { - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - background-color: var(--blur-bg); - border-radius: var(--border-radius-1); - border: 1px solid var(--blur-border); - padding: 10px 15px; - color: var(--colour-3); - display: flex; - justify-content: center; - align-items: center; - gap: 12px; - cursor: pointer; - animation: show_popup 0.4s; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: var(--blur-bg); + border-radius: var(--border-radius-1); + border: 1px solid var(--blur-border); + padding: 10px 15px; + color: var(--colour-3); + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + cursor: pointer; + animation: show_popup 0.4s; } @keyframes show_popup { - from { - opacity: 0; - transform: translateY(10px); - } + from { + opacity: 0; + transform: translateY(10px); + } } @keyframes hide_popup { - to { - opacity: 0; - transform: translateY(10px); - } + to { + opacity: 0; + transform: translateY(10px); + } } .stop_generating-hiding button { - animation: hide_popup 0.4s; + animation: hide_popup 0.4s; } .stop_generating-hidden button { - display: none; + display: none; } .typing { - position: absolute; - top: -25px; - left: 0; - font-size: 14px; - animation: show_popup 0.4s; + position: absolute; + top: -25px; + left: 0; + font-size: 14px; + animation: show_popup 0.4s; } .typing-hiding { - animation: hide_popup 0.4s; + animation: hide_popup 0.4s; } .typing-hidden { - display: none; + display: none; } input[type="checkbox"] { - height: 0; - width: 0; - display: none; + height: 0; + width: 0; + display: none; } label { - cursor: pointer; - text-indent: -9999px; - width: 50px; - height: 30px; - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - background-color: var(--blur-bg); - border-radius: var(--border-radius-1); - border: 1px solid var(--blur-border); - display: block; - border-radius: 100px; - position: relative; - overflow: hidden; - transition: 0.33s; + cursor: pointer; + text-indent: -9999px; + width: 50px; + height: 30px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: var(--blur-bg); + border-radius: var(--border-radius-1); + border: 1px solid var(--blur-border); + display: block; + border-radius: 100px; + position: relative; + overflow: hidden; + transition: 0.33s; } label:after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - left: 5px; - width: 20px; - height: 20px; - background: var(--colour-3); - border-radius: 90px; - transition: 0.33s; + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 5px; + width: 20px; + height: 20px; + background: var(--colour-3); + border-radius: 90px; + transition: 0.33s; } -input:checked+label { - background: var(--blur-border); +input:checked + label { + background: var(--blur-border); } -input:checked+label:after { - left: calc(100% - 5px - 20px); +input:checked + label:after { + left: calc(100% - 5px - 20px); } .buttons { - min-height: 10vh; - display: flex; - align-items: start; - justify-content: left; - width: 100%; + min-height: 10vh; + display: flex; + align-items: start; + justify-content: left; + width: 100%; } .field { - height: fit-content; - display: flex; - align-items: center; - gap: 16px; - padding-right: 15px + height: fit-content; + display: flex; + align-items: center; + gap: 16px; + padding-right: 15px; } .field .about { - font-size: 14px; - color: var(--colour-3); + font-size: 14px; + color: var(--colour-3); } .disable-scrollbars::-webkit-scrollbar { background: transparent; /* Chrome/Safari/Webkit */ width: 0px; } - + .disable-scrollbars { scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE 10+ */ + -ms-overflow-style: none; /* IE 10+ */ } select { - -webkit-border-radius: 8px; - -moz-border-radius: 8px; - border-radius: 8px; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; - -webkit-backdrop-filter: blur(20px); - backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); - cursor: pointer; - background-color: var(--blur-bg); - border: 1px solid var(--blur-border); - color: var(--colour-3); - display: block; - position: relative; - overflow: hidden; - outline: none; - padding: 8px 16px; + cursor: pointer; + background-color: var(--blur-bg); + border: 1px solid var(--blur-border); + color: var(--colour-3); + display: block; + position: relative; + overflow: hidden; + outline: none; + padding: 8px 16px; - appearance: none; + appearance: none; } .input-box { - display: flex; - align-items: center; - padding-right: 15px; - cursor: pointer; + display: flex; + align-items: center; + padding-right: 15px; + cursor: pointer; } .info { - padding: 8px 12px; - display: flex; - gap: 18px; - align-items: center; - user-select: none; - background: transparent; - border-radius: var(--border-radius-1); - width: 100%; - cursor: default; - border: 1px dashed var(--conversations) + padding: 8px 12px; + display: flex; + gap: 18px; + align-items: center; + user-select: none; + background: transparent; + border-radius: var(--border-radius-1); + width: 100%; + cursor: default; + border: 1px dashed var(--conversations); } .bottom_buttons { - width: 100%; - display: flex; - flex-direction: column; - gap: 10px; + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; } .bottom_buttons button { - padding: 8px 12px; - display: flex; - gap: 18px; - align-items: center; - cursor: pointer; - user-select: none; - background: transparent; - border: 1px solid #c7a2ff; - border-radius: var(--border-radius-1); - width: 100%; + padding: 8px 12px; + display: flex; + gap: 18px; + align-items: center; + cursor: pointer; + user-select: none; + background: transparent; + border: 1px solid #c7a2ff; + border-radius: var(--border-radius-1); + width: 100%; } .bottom_buttons button span { - color: var(--colour-3); - font-size: 14px; + color: var(--colour-3); + font-size: 14px; +} + +.settings-section { + width: 100%; + margin: 10px 0; + border-top: 1px solid var(--blur-border); + padding-top: 10px; +} + +.settings-toggle { + padding: 8px 12px; + display: flex; + gap: 18px; + align-items: center; + cursor: pointer; + user-select: none; + background: transparent; + border: 1px solid var(--conversations); + border-radius: var(--border-radius-1); + width: 100%; + justify-content: space-between; +} + +.settings-toggle span { + color: var(--colour-3); + font-size: 14px; + flex: 1; + text-align: left; +} + +.settings-toggle i { + color: var(--colour-3); + transition: transform 0.3s ease; +} + +.settings-toggle:hover { + border-color: var(--accent); +} + +.settings-content { + margin-top: 10px; + padding: 12px; + background: var(--blur-bg); + border: 1px solid var(--blur-border); + border-radius: var(--border-radius-1); +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-field label { + color: var(--colour-3); + font-size: 13px; + font-weight: 500; +} + +.api-key-input { + padding: 8px 12px; + background: var(--blur-bg); + border: 1px solid var(--blur-border); + border-radius: var(--border-radius-1); + color: var(--colour-3); + font-size: 13px; + width: 100%; + font-family: monospace; +} + +.api-key-input:focus { + outline: none; + border-color: var(--accent); +} + +.api-key-input::placeholder { + color: var(--user-input); + opacity: 0.6; +} + +.settings-actions { + display: flex; + gap: 8px; +} + +.save-api-key-btn, +.clear-api-key-btn { + padding: 6px 12px; + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; + user-select: none; + background: transparent; + border: 1px solid var(--conversations); + border-radius: var(--border-radius-1); + flex: 1; + justify-content: center; +} + +.save-api-key-btn span, +.clear-api-key-btn span { + color: var(--colour-3); + font-size: 12px; +} + +.save-api-key-btn:hover { + border-color: var(--accent); + background: rgba(139, 61, 255, 0.1); +} + +.clear-api-key-btn:hover { + border-color: #ff3d3d; + background: rgba(255, 61, 61, 0.1); +} + +.api-key-status { + font-size: 12px; + padding: 6px; + border-radius: var(--border-radius-1); + min-height: 20px; +} + +.api-key-status.success { + color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} + +.api-key-status.error { + color: #ff3d3d; + background: rgba(255, 61, 61, 0.1); +} + +.api-key-status.info { + color: var(--accent); + background: rgba(139, 61, 255, 0.1); } .conversations .top { - display: flex; - flex-direction: column; - gap: 16px; - overflow: auto; + display: flex; + flex-direction: column; + gap: 16px; + /* allow the left-top area to scroll vertically if conversation list grows */ + overflow-y: auto; +} + +/* Styles for the conversation list container we render into */ +.conversation-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 260px); + overflow-y: auto; +} + +.conversation-list-items .no-convos { + color: var(--colour-3); + opacity: 0.8; + padding: 8px 12px; + border-radius: 6px; + border: 1px dashed var(--conversations); } #cursor { - line-height: 17px; - margin-left: 3px; - -webkit-animation: blink 0.8s infinite; - animation: blink 0.8s infinite; - width: 7px; - height: 15px; + line-height: 17px; + margin-left: 3px; + -webkit-animation: blink 0.8s infinite; + animation: blink 0.8s infinite; + width: 7px; + height: 15px; } @keyframes blink { - 0% { - background: #ffffff00; - } + 0% { + background: #ffffff00; + } - 50% { - background: white; - } + 50% { + background: white; + } - 100% { - background: #ffffff00; - } + 100% { + background: #ffffff00; + } } @-webkit-keyframes blink { - 0% { - background: #ffffff00; - } + 0% { + background: #ffffff00; + } - 50% { - background: white; - } + 50% { + background: white; + } - 100% { - background: #ffffff00; - } + 100% { + background: #ffffff00; + } } - ol, ul { - padding-left: 20px; + padding-left: 20px; } - @keyframes spinner { - to { - transform: rotate(360deg); - } + to { + transform: rotate(360deg); + } } .spinner:before { - content: ''; - box-sizing: border-box; - position: absolute; - top: 50%; - left: 45%; - width: 20px; - height: 20px; + content: ""; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 45%; + width: 20px; + height: 20px; - border-radius: 50%; - border: 1px solid var(--conversations); - border-top-color: white; - animation: spinner .6s linear infinite; + border-radius: 50%; + border: 1px solid var(--conversations); + border-top-color: white; + animation: spinner 0.6s linear infinite; } .grecaptcha-badge { - visibility: hidden; + visibility: hidden; } .mobile-sidebar { - display: none !important; - position: absolute; - z-index: 100000; - top: 0; - left: 0; - margin: 10px; - font-size: 20px; - cursor: pointer; - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - background-color: var(--blur-bg); - border-radius: 10px; - border: 1px solid var(--blur-border); - width: 40px; - height: 40px; - justify-content: center; - align-items: center; - transition: 0.33s; + display: none !important; + position: absolute; + z-index: 100000; + top: 0; + left: 0; + margin: 10px; + font-size: 20px; + cursor: pointer; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: var(--blur-bg); + border-radius: 10px; + border: 1px solid var(--blur-border); + width: 40px; + height: 40px; + justify-content: center; + align-items: center; + transition: 0.33s; } .mobile-sidebar i { - transition: 0.33s; + transition: 0.33s; } .rotated { - transform: rotate(360deg); + transform: rotate(360deg); } @media screen and (max-width: 990px) { - .conversations { - display: none; - width: 100%; - max-width: none; - } + .conversations { + display: none; + width: 100%; + max-width: none; + } - .buttons { + .buttons { flex-wrap: wrap; gap: 5px; padding-bottom: 10vh; margin-bottom: 10vh; -} + } - .field { + .field { min-height: 5%; width: fit-content; -} + } - .mobile-sidebar { - display: flex !important; - } + .mobile-sidebar { + display: flex !important; + } } @media screen and (max-height: 640px) { - body { - height: 87vh - } + body { + height: 87vh; + } } - .shown { - display: flex; + display: flex; } - a:-webkit-any-link { - color: var(--accent); + color: var(--accent); } .conversation .user-input textarea { - font-size: 15px; - width: 100%; - height: 100%; - padding: 12px 15px; - background: none; - border: none; - outline: none; - color: var(--colour-3); + font-size: 15px; + width: 100%; + /* give a static-ish size so layout doesn't jump */ + height: 110px; + padding: 12px 15px; + background: none; + border: none; + outline: none; + color: var(--colour-3); - resize: vertical; - max-height: 150px; - min-height: 80px; + resize: vertical; + max-height: 180px; + min-height: 80px; } /* style for hljs copy */ .hljs-copy-wrapper { - position: relative; - overflow: hidden + position: relative; + overflow: hidden; } .hljs-copy-wrapper:hover .hljs-copy-button, .hljs-copy-button:focus { - transform: translateX(0) + transform: translateX(0); } .hljs-copy-button { - position: absolute; - transform: translateX(calc(100% + 1.125em)); - top: 1em; - right: 1em; - width: 2rem; - height: 2rem; - text-indent: -9999px; - color: #fff; - border-radius: .25rem; - border: 1px solid #ffffff22; - background-color: #2d2b57; - background-image: url('data:image/svg+xml;utf-8,'); - background-repeat: no-repeat; - background-position: center; - transition: background-color 200ms ease, transform 200ms ease-out + position: absolute; + transform: translateX(calc(100% + 1.125em)); + top: 1em; + right: 1em; + width: 2rem; + height: 2rem; + text-indent: -9999px; + color: #fff; + border-radius: 0.25rem; + border: 1px solid #ffffff22; + background-color: #2d2b57; + background-image: url('data:image/svg+xml;utf-8,'); + background-repeat: no-repeat; + background-position: center; + transition: background-color 200ms ease, transform 200ms ease-out; } .hljs-copy-button:hover { - border-color: #ffffff44 + border-color: #ffffff44; } .hljs-copy-button:active { - border-color: #ffffff66 + border-color: #ffffff66; } .hljs-copy-button[data-copied="true"] { - text-indent: 0; - width: auto; - background-image: none + text-indent: 0; + width: auto; + background-image: none; } -@media(prefers-reduced-motion) { - .hljs-copy-button { - transition: none - } +@media (prefers-reduced-motion) { + .hljs-copy-button { + transition: none; + } } .hljs-copy-alert { - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } .visually-hidden { - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; -} - - -.color-picker>fieldset { - border: 0; - display: flex; - width: fit-content; - background: var(--colour-1); - margin-inline: auto; - border-radius: 8px; - -webkit-backdrop-filter: blur(20px); - backdrop-filter: blur(20px); - cursor: pointer; - background-color: var(--blur-bg); - border: 1px solid var(--blur-border); - color: var(--colour-3); - display: block; - position: relative; - overflow: hidden; - outline: none; - padding: 6px 16px; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.color-picker > fieldset { + border: 0; + display: flex; + width: fit-content; + background: var(--colour-1); + margin-inline: auto; + border-radius: 8px; + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + cursor: pointer; + background-color: var(--blur-bg); + border: 1px solid var(--blur-border); + color: var(--colour-3); + display: block; + position: relative; + overflow: hidden; + outline: none; + padding: 6px 16px; } .color-picker input[type="radio"]:checked { - background-color: var(--radio-color); + background-color: var(--radio-color); } .color-picker input[type="radio"]#light { - --radio-color: gray; + --radio-color: gray; } .color-picker input[type="radio"]#pink { - --radio-color: pink; + --radio-color: pink; } .color-picker input[type="radio"]#blue { - --radio-color: blue; + --radio-color: blue; } .color-picker input[type="radio"]#green { - --radio-color: green; + --radio-color: green; } .color-picker input[type="radio"]#dark { - --radio-color: #232323; + --radio-color: #232323; } .pink { - --colour-1: hsl(310 50% 90%); - --clr-card-bg: hsl(310 50% 100%); - --colour-3: hsl(310 50% 15%); - --conversations: hsl(310 50% 25%); + --colour-1: hsl(310 50% 90%); + --clr-card-bg: hsl(310 50% 100%); + --colour-3: hsl(310 50% 15%); + --conversations: hsl(310 50% 25%); } .blue { - --colour-1: hsl(209 50% 90%); - --clr-card-bg: hsl(209 50% 100%); - --colour-3: hsl(209 50% 15%); - --conversations: hsl(209 50% 25%); + --colour-1: hsl(209 50% 90%); + --clr-card-bg: hsl(209 50% 100%); + --colour-3: hsl(209 50% 15%); + --conversations: hsl(209 50% 25%); } .green { - --colour-1: hsl(109 50% 90%); - --clr-card-bg: hsl(109 50% 100%); - --colour-3: hsl(109 50% 15%); - --conversations: hsl(109 50% 25%); + --colour-1: hsl(109 50% 90%); + --clr-card-bg: hsl(109 50% 100%); + --colour-3: hsl(109 50% 15%); + --conversations: hsl(109 50% 25%); } .dark { - --colour-1: hsl(209 50% 10%); - --clr-card-bg: hsl(209 50% 5%); - --colour-3: hsl(209 50% 90%); - --conversations: hsl(209 50% 80%); + --colour-1: hsl(209 50% 10%); + --clr-card-bg: hsl(209 50% 5%); + --colour-3: hsl(209 50% 90%); + --conversations: hsl(209 50% 80%); } :root:has(#pink:checked) { - --colour-1: hsl(310 50% 90%); - --clr-card-bg: hsl(310 50% 100%); - --colour-3: hsl(310 50% 15%); - --conversations: hsl(310 50% 25%); + --colour-1: hsl(310 50% 90%); + --clr-card-bg: hsl(310 50% 100%); + --colour-3: hsl(310 50% 15%); + --conversations: hsl(310 50% 25%); } :root:has(#blue:checked) { - --colour-1: hsl(209 50% 90%); - --clr-card-bg: hsl(209 50% 100%); - --colour-3: hsl(209 50% 15%); - --conversations: hsl(209 50% 25%); + --colour-1: hsl(209 50% 90%); + --clr-card-bg: hsl(209 50% 100%); + --colour-3: hsl(209 50% 15%); + --conversations: hsl(209 50% 25%); } :root:has(#green:checked) { - --colour-1: hsl(109 50% 90%); - --clr-card-bg: hsl(109 50% 100%); - --colour-3: hsl(109 50% 15%); - --conversations: hsl(109 50% 25%); + --colour-1: hsl(109 50% 90%); + --clr-card-bg: hsl(109 50% 100%); + --colour-3: hsl(109 50% 15%); + --conversations: hsl(109 50% 25%); } :root:has(#dark:checked) { - --colour-1: hsl(209 50% 10%); - --clr-card-bg: hsl(209 50% 5%); - --colour-3: hsl(209 50% 90%); - --conversations: hsl(209 50% 80%); -} \ No newline at end of file + --colour-1: hsl(209 50% 10%); + --clr-card-bg: hsl(209 50% 5%); + --colour-3: hsl(209 50% 90%); + --conversations: hsl(209 50% 80%); +} + +/* Small layout fixes for input and new-convo visibility */ +.conversations .top { + /* ensure the top area can always show the new conversation button */ + min-height: 84px; + padding-bottom: 8px; +} + +.new_convo { + align-items: center; + display: flex; + gap: 8px; +} + +.box.input-box { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; +} + +.input-box textarea#message-input { + width: 100%; + min-height: 80px; + max-height: 180px; + resize: vertical; + padding: 12px; + box-sizing: border-box; + background: none; + border: none; + outline: none; + color: var(--colour-3); +} + +#send-button { + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +/* ensure the input box stays above decorative gradients */ +.box.input-box { + z-index: 2; +} diff --git a/client/html/index.html b/client/html/index.html index 201ac155..d0a3b234 100644 --- a/client/html/index.html +++ b/client/html/index.html @@ -1,160 +1,232 @@ - - - - - - - - - - - - - - - - - - - - - - - - ChatGPT - - -
-
-
-
- -
-
-
- -
- - By: @xtekky
- Version: 0.0.1-beta
- Release: 2023-04-18
-
-
-
-
-
-
- -
-
-
-
-
- -
- -
-
-
-
-
- - - Web Access -
-
- - -
-
- -
+ /* Handle */ + #message-input::-webkit-scrollbar-thumb { + background: #c7a2ff; + } -
-
- Pick a color scheme - - - - - - - - - - - - - - -
-
-
+ /* Handle on hover */ + #message-input::-webkit-scrollbar-thumb:hover { + background: #8b3dff; + } + + + ChatGPT + + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+
-
- +
+
+ + + Web Access +
+
+ + +
+
+ +
+ +
+
+ Pick a color scheme + + + + + + + + + + + + + + +
+
- +
+
+
+ +
+ diff --git a/client/js/api.js b/client/js/api.js new file mode 100644 index 00000000..75f36e67 --- /dev/null +++ b/client/js/api.js @@ -0,0 +1,103 @@ +// Minimal API module: streaming POST to backend conversation endpoint. +// Exports streamConversation(payload, onChunk, signal) -> returns final accumulated text. + +export async function streamConversation(payload, onChunk, signal) { + const url = '/backend-api/v2/conversation'; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(payload), + signal + }); + + if (!res.ok) { + // attempt to read response body for better error messages + const body = await res.text().catch(() => ''); + throw new Error(`Request failed: ${res.status} ${res.statusText}${body ? ' - ' + body : ''}`); + } + + if (!res.body) { + throw new Error('Response has no body stream'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let finalText = ''; + + // We'll parse Server-Sent Events (SSE) framed as one or more 'data: ...' lines + // separated by a blank line (\n\n). The server emits JSON payloads in + // each data: event in the form {"text": "..."}. + let buffer = ''; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + + // Basic protection: detect common HTML/CF challenge responses and convert to readable text + if (chunk.includes('
Attention Required')) { + const msg = 'Error: Cloudflare/edge returned an HTML challenge. Refresh the page or check the server.'; + finalText += msg; + try { if (typeof onChunk === 'function') onChunk(msg); } catch (e) { /* ignore */ } + continue; + } + + buffer += chunk; + + // Process complete SSE events (separated by \n\n) + while (true) { + const idx = buffer.indexOf('\n\n'); + if (idx === -1) break; + const rawEvent = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + + // Extract data: lines (may be multiple) and concatenate their payloads + const lines = rawEvent.split(/\r?\n/); + let dataPayload = ''; + for (const line of lines) { + if (line.startsWith('data:')) { + dataPayload += line.slice(5).trim(); + } + } + + if (!dataPayload) continue; + + // Try parsing JSON payloads emitted by the server: {"text":"..."} + let text = dataPayload; + try { + const parsed = JSON.parse(dataPayload); + if (parsed && typeof parsed.text === 'string') text = parsed.text; + } catch (e) { + // not JSON — keep raw payload + } + + finalText += text; + try { if (typeof onChunk === 'function') onChunk(text); } catch (e) { /* ignore */ } + } + } + } catch (err) { + // Propagate AbortError to allow callers to detect cancellation + throw err; + } finally { + try { reader.releaseLock(); } catch (e) { /* ignore */ } + } + + // if any leftover buffer contains text (no trailing \n\n), try to process it + if (buffer) { + let text = buffer; + try { + const parsed = JSON.parse(buffer); + if (parsed && typeof parsed.text === 'string') text = parsed.text; + } catch (e) { /* ignore */ } + finalText += text; + try { if (typeof onChunk === 'function') onChunk(text); } catch (e) { /* ignore */ } + } + + return finalText; +} \ No newline at end of file diff --git a/client/js/chat.js b/client/js/chat.js deleted file mode 100644 index cff0be5e..00000000 --- a/client/js/chat.js +++ /dev/null @@ -1,567 +0,0 @@ -const query = (obj) => - Object.keys(obj) - .map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(obj[k])) - .join("&"); -const colorThemes = document.querySelectorAll('[name="theme"]'); -const markdown = window.markdownit(); -const message_box = document.getElementById(`messages`); -const message_input = document.getElementById(`message-input`); -const box_conversations = document.querySelector(`.top`); -const spinner = box_conversations.querySelector(".spinner"); -const stop_generating = document.querySelector(`.stop_generating`); -const send_button = document.querySelector(`#send-button`); -let prompt_lock = false; - -hljs.addPlugin(new CopyButtonPlugin()); - -function resizeTextarea(textarea) { - textarea.style.height = '80px'; - textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; -} - -const format = (text) => { - return text.replace(/(?:\r\n|\r|\n)/g, "
"); -}; - -message_input.addEventListener("blur", () => { - window.scrollTo(0, 0); -}); - -message_input.addEventListener("focus", () => { - document.documentElement.scrollTop = document.documentElement.scrollHeight; -}); - -const delete_conversations = async () => { - localStorage.clear(); - await new_conversation(); -}; - -const handle_ask = async () => { - message_input.style.height = `80px`; - message_input.focus(); - - window.scrollTo(0, 0); - let message = message_input.value; - - if (message.length > 0) { - message_input.value = ``; - await ask_gpt(message); - } -}; - -const remove_cancel_button = async () => { - stop_generating.classList.add(`stop_generating-hiding`); - - setTimeout(() => { - stop_generating.classList.remove(`stop_generating-hiding`); - stop_generating.classList.add(`stop_generating-hidden`); - }, 300); -}; - -const ask_gpt = async (message) => { - try { - message_input.value = ``; - message_input.innerHTML = ``; - message_input.innerText = ``; - - add_conversation(window.conversation_id, message.substr(0, 20)); - window.scrollTo(0, 0); - window.controller = new AbortController(); - - jailbreak = document.getElementById("jailbreak"); - model = document.getElementById("model"); - prompt_lock = true; - window.text = ``; - window.token = message_id(); - - stop_generating.classList.remove(`stop_generating-hidden`); - - message_box.innerHTML += ` -
-
- ${user_image} - -
-
- ${format(message)} -
-
- `; - - /* .replace(/(?:\r\n|\r|\n)/g, '
') */ - - message_box.scrollTop = message_box.scrollHeight; - window.scrollTo(0, 0); - await new Promise((r) => setTimeout(r, 500)); - window.scrollTo(0, 0); - - message_box.innerHTML += ` -
-
- ${gpt_image} -
-
-
-
-
- `; - - message_box.scrollTop = message_box.scrollHeight; - window.scrollTo(0, 0); - await new Promise((r) => setTimeout(r, 1000)); - window.scrollTo(0, 0); - - const response = await fetch(`/backend-api/v2/conversation`, { - method: `POST`, - signal: window.controller.signal, - headers: { - "content-type": `application/json`, - accept: `text/event-stream`, - }, - body: JSON.stringify({ - conversation_id: window.conversation_id, - action: `_ask`, - model: model.options[model.selectedIndex].value, - jailbreak: jailbreak.options[jailbreak.selectedIndex].value, - meta: { - id: window.token, - content: { - conversation: await get_conversation(window.conversation_id), - internet_access: document.getElementById("switch").checked, - content_type: "text", - parts: [ - { - content: message, - role: "user", - }, - ], - }, - }, - }), - }); - - const reader = response.body.getReader(); - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - chunk = new TextDecoder().decode(value); - - if ( - chunk.includes( - ` { - const elements = box_conversations.childNodes; - let index = elements.length; - - if (index > 0) { - while (index--) { - const element = elements[index]; - if ( - element.nodeType === Node.ELEMENT_NODE && - element.tagName.toLowerCase() !== `button` - ) { - box_conversations.removeChild(element); - } - } - } -}; - -const clear_conversation = async () => { - let messages = message_box.getElementsByTagName(`div`); - - while (messages.length > 0) { - message_box.removeChild(messages[0]); - } -}; - -const show_option = async (conversation_id) => { - const conv = document.getElementById(`conv-${conversation_id}`); - const yes = document.getElementById(`yes-${conversation_id}`); - const not = document.getElementById(`not-${conversation_id}`); - - conv.style.display = "none"; - yes.style.display = "block"; - not.style.display = "block"; -} - -const hide_option = async (conversation_id) => { - const conv = document.getElementById(`conv-${conversation_id}`); - const yes = document.getElementById(`yes-${conversation_id}`); - const not = document.getElementById(`not-${conversation_id}`); - - conv.style.display = "block"; - yes.style.display = "none"; - not.style.display = "none"; -} - -const delete_conversation = async (conversation_id) => { - localStorage.removeItem(`conversation:${conversation_id}`); - - const conversation = document.getElementById(`convo-${conversation_id}`); - conversation.remove(); - - if (window.conversation_id == conversation_id) { - await new_conversation(); - } - - await load_conversations(20, 0, true); -}; - -const set_conversation = async (conversation_id) => { - history.pushState({}, null, `/chat/${conversation_id}`); - window.conversation_id = conversation_id; - - await clear_conversation(); - await load_conversation(conversation_id); - await load_conversations(20, 0, true); -}; - -const new_conversation = async () => { - history.pushState({}, null, `/chat/`); - window.conversation_id = uuid(); - - await clear_conversation(); - await load_conversations(20, 0, true); -}; - -const load_conversation = async (conversation_id) => { - let conversation = await JSON.parse( - localStorage.getItem(`conversation:${conversation_id}`) - ); - console.log(conversation, conversation_id); - - for (item of conversation.items) { - message_box.innerHTML += ` -
-
- ${item.role == "assistant" ? gpt_image : user_image} - ${ - item.role == "assistant" - ? `` - : `` - } -
-
- ${ - item.role == "assistant" - ? markdown.render(item.content) - : item.content - } -
-
- `; - } - - document.querySelectorAll(`code`).forEach((el) => { - hljs.highlightElement(el); - }); - - message_box.scrollTo({ top: message_box.scrollHeight, behavior: "smooth" }); - - setTimeout(() => { - message_box.scrollTop = message_box.scrollHeight; - }, 500); -}; - -const get_conversation = async (conversation_id) => { - let conversation = await JSON.parse( - localStorage.getItem(`conversation:${conversation_id}`) - ); - return conversation.items; -}; - -const add_conversation = async (conversation_id, title) => { - if (localStorage.getItem(`conversation:${conversation_id}`) == null) { - localStorage.setItem( - `conversation:${conversation_id}`, - JSON.stringify({ - id: conversation_id, - title: title, - items: [], - }) - ); - } -}; - -const add_message = async (conversation_id, role, content) => { - before_adding = JSON.parse( - localStorage.getItem(`conversation:${conversation_id}`) - ); - - before_adding.items.push({ - role: role, - content: content, - }); - - localStorage.setItem( - `conversation:${conversation_id}`, - JSON.stringify(before_adding) - ); // update conversation -}; - -const load_conversations = async (limit, offset, loader) => { - //console.log(loader); - //if (loader === undefined) box_conversations.appendChild(spinner); - - let conversations = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("conversation:")) { - let conversation = localStorage.getItem(localStorage.key(i)); - conversations.push(JSON.parse(conversation)); - } - } - - //if (loader === undefined) spinner.parentNode.removeChild(spinner) - await clear_conversations(); - - for (conversation of conversations) { - box_conversations.innerHTML += ` -
-
- - ${conversation.title} -
- - - -
- `; - } - - document.querySelectorAll(`code`).forEach((el) => { - hljs.highlightElement(el); - }); -}; - -document.getElementById(`cancelButton`).addEventListener(`click`, async () => { - window.controller.abort(); - console.log(`aborted ${window.conversation_id}`); -}); - -function h2a(str1) { - var hex = str1.toString(); - var str = ""; - - for (var n = 0; n < hex.length; n += 2) { - str += String.fromCharCode(parseInt(hex.substr(n, 2), 16)); - } - - return str; -} - -const uuid = () => { - return `xxxxxxxx-xxxx-4xxx-yxxx-${Date.now().toString(16)}`.replace( - /[xy]/g, - function (c) { - var r = (Math.random() * 16) | 0, - v = c == "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - } - ); -}; - -const message_id = () => { - random_bytes = (Math.floor(Math.random() * 1338377565) + 2956589730).toString( - 2 - ); - unix = Math.floor(Date.now() / 1000).toString(2); - - return BigInt(`0b${unix}${random_bytes}`).toString(); -}; - -window.onload = async () => { - load_settings_localstorage(); - - conversations = 0; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("conversation:")) { - conversations += 1; - } - } - - if (conversations == 0) localStorage.clear(); - - await setTimeout(() => { - load_conversations(20, 0); - }, 1); - - if (!window.location.href.endsWith(`#`)) { - if (/\/chat\/.+/.test(window.location.href)) { - await load_conversation(window.conversation_id); - } - } - -message_input.addEventListener(`keydown`, async (evt) => { - if (prompt_lock) return; - if (evt.keyCode === 13 && !evt.shiftKey) { - evt.preventDefault(); - console.log('pressed enter'); - await handle_ask(); - } else { - message_input.style.removeProperty("height"); - message_input.style.height = message_input.scrollHeight + 4 + "px"; - } - }); - - send_button.addEventListener(`click`, async () => { - console.log("clicked send"); - if (prompt_lock) return; - await handle_ask(); - }); - - register_settings_localstorage(); -}; - -document.querySelector(".mobile-sidebar").addEventListener("click", (event) => { - const sidebar = document.querySelector(".conversations"); - - if (sidebar.classList.contains("shown")) { - sidebar.classList.remove("shown"); - event.target.classList.remove("rotated"); - } else { - sidebar.classList.add("shown"); - event.target.classList.add("rotated"); - } - - window.scrollTo(0, 0); -}); - -const register_settings_localstorage = async () => { - settings_ids = ["switch", "model", "jailbreak"]; - settings_elements = settings_ids.map((id) => document.getElementById(id)); - settings_elements.map((element) => - element.addEventListener(`change`, async (event) => { - switch (event.target.type) { - case "checkbox": - localStorage.setItem(event.target.id, event.target.checked); - break; - case "select-one": - localStorage.setItem(event.target.id, event.target.selectedIndex); - break; - default: - console.warn("Unresolved element type"); - } - }) - ); -}; - -const load_settings_localstorage = async () => { - settings_ids = ["switch", "model", "jailbreak"]; - settings_elements = settings_ids.map((id) => document.getElementById(id)); - settings_elements.map((element) => { - if (localStorage.getItem(element.id)) { - switch (element.type) { - case "checkbox": - element.checked = localStorage.getItem(element.id) === "true"; - break; - case "select-one": - element.selectedIndex = parseInt(localStorage.getItem(element.id)); - break; - default: - console.warn("Unresolved element type"); - } - } - }); -}; - -// Theme storage for recurring viewers -const storeTheme = function (theme) { - localStorage.setItem("theme", theme); -}; - -// set theme when visitor returns -const setTheme = function () { - const activeTheme = localStorage.getItem("theme"); - colorThemes.forEach((themeOption) => { - if (themeOption.id === activeTheme) { - themeOption.checked = true; - } - }); - // fallback for no :has() support - document.documentElement.className = activeTheme; -}; - -colorThemes.forEach((themeOption) => { - themeOption.addEventListener("click", () => { - storeTheme(themeOption.id); - // fallback for no :has() support - document.documentElement.className = themeOption.id; - }); -}); - -document.onload = setTheme(); diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 00000000..e8f0eba5 --- /dev/null +++ b/client/js/main.js @@ -0,0 +1,327 @@ +// Minimal entrypoint (ES module) — wires UI to api/store/ui/utils modules. + +import { streamConversation } from "./api.js"; +import * as store from "./store.js"; +import { + renderUserMessage, + createAssistantPlaceholder, + renderAssistantChunk, + clearMessages, + showError, + scrollToBottom, + renderConversationList, +} from "./ui.js"; +import { message_id, uuid, resizeTextarea } from "./utils.js"; + +let currentAbort = null; + +// Enable a client-side mock mode for testing the UI without a backend/API key. +// Activate by visiting the app URL with `#local` (e.g. http://localhost:1338/chat/#local) +const MOCK_MODE = + typeof location !== "undefined" && + location.hash && + location.hash.includes("local"); + +async function handleSend() { + const inputEl = document.getElementById("message-input"); + if (!inputEl) return; + const text = inputEl.value.trim(); + if (!text) return; + + inputEl.value = ""; + resizeTextarea(inputEl); + + const convId = window.conversation_id || uuid(); + store.addConversation(convId, convId); + store.addMessage(convId, "user", text); + + const token = message_id(); + renderUserMessage(token, text); + createAssistantPlaceholder(token); + + if (currentAbort) currentAbort.abort(); + currentAbort = new AbortController(); + + // stable IDs for the request + let userId = localStorage.getItem("user_id"); + if (!userId) { + userId = `user_${uuid().slice(0, 8)}`; + localStorage.setItem("user_id", userId); + } + const teamId = localStorage.getItem("team_id") || null; + + // Get custom API key from localStorage if available + const customApiKey = localStorage.getItem("custom_api_key"); + + const payload = { + conversation_id: convId, + action: "_ask", + model: document.getElementById("model")?.value || "default", + jailbreak: document.getElementById("jailbreak")?.value || "false", + ...(customApiKey ? { api_key: customApiKey } : {}), + meta: { + id: message_id(), + user: { + user_id: userId, + ...(teamId ? { team_id: teamId } : {}), + }, + content: { + conversation: (await store.getConversation(convId)).messages, + internet_access: document.getElementById("switch")?.checked || false, + content_type: "text", + parts: [{ content: text, role: "user" }], + }, + }, + }; + document.getElementById("join-team")?.addEventListener("click", () => { + alert("Join Team clicked!"); + }); + + // If MOCK_MODE is active, simulate a streaming assistant response locally + let acc = ""; + if (MOCK_MODE) { + const simulated = `Echo: ${text}\n\n(This is a local UI-only simulated response.)`; + // simulate streaming in small chunks + const chunks = []; + for (let i = 0; i < simulated.length; i += 20) + chunks.push(simulated.slice(i, i + 20)); + + try { + for (const c of chunks) { + if (currentAbort && currentAbort.signal.aborted) + throw new DOMException("Aborted", "AbortError"); + await new Promise((r) => setTimeout(r, 120)); + acc += c; + renderAssistantChunk(token, acc); + } + store.addMessage(convId, "assistant", acc); + } catch (err) { + if (err.name === "AbortError") { + renderAssistantChunk(token, acc + " [aborted]"); + } else { + showError("Local mock failed"); + console.error(err); + renderAssistantChunk(token, acc + " [error]"); + } + } finally { + currentAbort = null; + // force scroll at end so user sees final content + scrollToBottom(true); + } + return; + } + + try { + await streamConversation( + payload, + (chunk) => { + acc += chunk; + renderAssistantChunk(token, acc); + }, + currentAbort.signal + ); + + store.addMessage(convId, "assistant", acc); + } catch (err) { + if (err.name === "AbortError") { + renderAssistantChunk(token, acc + " [aborted]"); + } else { + showError("Failed to get response from server"); + console.error(err); + renderAssistantChunk(token, acc + " [error]"); + } + } finally { + currentAbort = null; + scrollToBottom(); + } +} + +function handleCancel() { + if (currentAbort) currentAbort.abort(); +} + +async function setConversation(id, conv) { + window.conversation_id = id; + clearMessages(); + if (!conv) conv = await store.getConversation(id); + for (const m of conv.messages) { + if (m.role === "user") { + const t = message_id(); + renderUserMessage(t, m.content); + } else { + const t = message_id(); + createAssistantPlaceholder(t); + renderAssistantChunk(t, m.content); + } + } +} + +export async function init() { + const sendBtn = document.getElementById("send-button"); + const cancelBtn = document.getElementById("cancelButton"); + const inputEl = document.getElementById("message-input"); + + if (sendBtn) sendBtn.addEventListener("click", () => handleSend()); + if (cancelBtn) cancelBtn.addEventListener("click", () => handleCancel()); + if (inputEl) { + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }); + } + + // render into the dedicated list container; this keeps the New Conversation + // button and spinner intact (they live in #conversations) + const listEl = + document.getElementById("conversation-list") || + document.getElementById("conversations"); + const handlers = { + onSelect: async (id) => { + const c = await store.getConversation(id); + if (c) setConversation(id, c); + }, + onDelete: async (id) => { + await store.deleteConversation(id); + const l2 = await store.listConversations(); + if (listEl) renderConversationList(listEl, l2, handlers); + }, + onShowOption: (id) => { + console.log("show options for", id); + }, + }; + + if (listEl) { + const list = await store.listConversations(); + renderConversationList(listEl, list, handlers); + } + + // focus the input so mobile/desktop shows the input area immediately + if (inputEl) { + try { + inputEl.focus(); + } catch (e) { + /* ignore */ + } + } + + // wire header buttons that previously used inline onclick attributes + const newBtn = document.getElementById("new-convo-button"); + if (newBtn) { + newBtn.addEventListener("click", async () => { + const id = uuid(); + window.conversation_id = id; + store.addConversation(id, id); + clearMessages(); + const list = await store.listConversations(); + if (listEl) renderConversationList(listEl, list, handlers); + // focus input after creating a new conversation + if (inputEl) { + try { + inputEl.focus(); + } catch (e) {} + } + }); + } + + const clearBtn = document.getElementById("clear-conversations-button"); + if (clearBtn) { + clearBtn.addEventListener("click", async () => { + store.clearConversations(); + clearMessages(); + if (listEl) renderConversationList(listEl, [], handlers); + }); + } + //create/persist a team_id when clicked + const joinBtn = document.getElementById("join-team-button"); + if (joinBtn) { + // 1) Force the initial label every load (overrides any other JS writing to it) + joinBtn.innerHTML = "Join team A"; + + // 2) Only create/persist the team_id on click — do NOT change the label + joinBtn.addEventListener("click", () => { + let teamId = localStorage.getItem("team_id"); + if (!teamId) { + const raw = uuid(); + teamId = `team_${raw.slice(0, 8)}`; + localStorage.setItem("team_id", teamId); + } + // keep label as "Join team A" + }); + } + + // Initialize API key settings + initApiKeySettings(); +} + +function initApiKeySettings() { + const settingsToggle = document.getElementById("settings-toggle-button"); + const settingsContent = document.getElementById("settings-content"); + const settingsChevron = document.getElementById("settings-chevron"); + const apiKeyInput = document.getElementById("api-key-input"); + const saveBtn = document.getElementById("save-api-key-button"); + const clearBtn = document.getElementById("clear-api-key-button"); + const statusDiv = document.getElementById("api-key-status"); + + // Load saved API key on page load (don't show status message on initial load) + const savedApiKey = localStorage.getItem("custom_api_key"); + if (savedApiKey && apiKeyInput) { + apiKeyInput.value = savedApiKey; + // Add visual indicator that key is saved + apiKeyInput.style.borderColor = "var(--accent)"; + } + + // Toggle settings visibility + if (settingsToggle && settingsContent) { + settingsToggle.addEventListener("click", () => { + const isHidden = settingsContent.style.display === "none"; + settingsContent.style.display = isHidden ? "block" : "none"; + if (settingsChevron) { + settingsChevron.style.transform = isHidden ? "rotate(180deg)" : "rotate(0deg)"; + } + }); + } + + // Save API key + if (saveBtn && apiKeyInput) { + saveBtn.addEventListener("click", () => { + const apiKey = apiKeyInput.value.trim(); + if (apiKey) { + localStorage.setItem("custom_api_key", apiKey); + apiKeyInput.style.borderColor = "var(--accent)"; + showStatus("API key saved successfully", "success"); + } else { + apiKeyInput.style.borderColor = ""; + showStatus("Please enter an API key", "error"); + } + }); + } + + // Clear API key + if (clearBtn && apiKeyInput) { + clearBtn.addEventListener("click", () => { + apiKeyInput.value = ""; + apiKeyInput.style.borderColor = ""; + localStorage.removeItem("custom_api_key"); + showStatus("API key cleared", "info"); + }); + } + + function showStatus(message, type) { + if (!statusDiv) return; + statusDiv.textContent = message; + statusDiv.className = `api-key-status ${type}`; + setTimeout(() => { + if (statusDiv) { + statusDiv.textContent = ""; + statusDiv.className = "api-key-status"; + } + }, 3000); + } +} + +// auto-init on load +window.addEventListener("load", () => { + init().catch(console.error); +}); diff --git a/client/js/store.js b/client/js/store.js new file mode 100644 index 00000000..0f37a91e --- /dev/null +++ b/client/js/store.js @@ -0,0 +1,141 @@ +// Client-side conversation storage (localStorage fallback to in-memory). +// Exports: getConversation, saveConversation, addConversation, addMessage, +// listConversations, deleteConversation, clearConversations + +const PREFIX = 'conv:'; +const inMemory = new Map(); + +function storageAvailable() { + try { + const testKey = '__storage_test__'; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } +} + +function key(id) { + return `${PREFIX}${id}`; +} + + +function safeParse(raw) { + try { return JSON.parse(raw); } catch (e) { return null; } +} + +function readRaw(k) { + if (storageAvailable()) { + return localStorage.getItem(k); + } + return inMemory.get(k) ?? null; +} + +function writeRaw(k, v) { + if (storageAvailable()) { + localStorage.setItem(k, v); + return; + } + inMemory.set(k, v); +} + +/** + * Get conversation object by id. + * Returns { id, title, messages: [] } or a fresh skeleton if missing. + */ +export function getConversation(id) { + if (!id) return { id: null, title: null, messages: [] }; + const raw = readRaw(key(id)); + let conv = safeParse(raw); + if (!conv) { + // return skeleton when there is no stored conversation in the new format + return { id, title: id, messages: [] }; + } + // expected new-format shape: messages is an array + conv.messages = Array.isArray(conv.messages) ? conv.messages : []; + return { id: conv.id || id, title: conv.title || id, messages: conv.messages }; +} + +/** Persist a full conversation object */ +export function saveConversation(conv) { + if (!conv || !conv.id) throw new Error('Conversation must have an id'); + const out = { + id: conv.id, + title: conv.title || conv.id, + messages: Array.isArray(conv.messages) ? conv.messages : [], + created_at: conv.created_at || Date.now(), + updated_at: Date.now(), + }; + writeRaw(key(conv.id), JSON.stringify(out)); +} + +/** Create a conversation if missing */ +export function addConversation(id, title = null) { + if (!id) throw new Error('id required'); + const existing = getConversation(id); + if (existing && existing.messages && existing.messages.length) return existing; + const conv = { id, title: title || id, messages: [], created_at: Date.now(), updated_at: Date.now() }; + saveConversation(conv); + return conv; +} + +/** Append a message to a conversation and persist it. + * message: role: 'user'|'assistant'|'system', content: string + */ +export function addMessage(id, role, content) { + if (!id) throw new Error('Conversation id required'); + const conv = getConversation(id); + const msg = { role: role || 'user', content: (content == null ? '' : content), ts: Date.now() }; + conv.messages.push(msg); + saveConversation(conv); + return msg; +} + +/** List all stored conversations (returns array of conversation objects) */ +export function listConversations() { + const out = []; + if (storageAvailable()) { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !k.startsWith(PREFIX)) continue; + const conv = safeParse(localStorage.getItem(k)); + if (conv && conv.id) out.push(conv); + } + } else { + for (const [k, v] of inMemory.entries()) { + if (!k.startsWith(PREFIX)) continue; + const conv = safeParse(v); + if (conv && conv.id) out.push(conv); + } + } + out.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0)); + return out; +} + +/** Delete single conversation */ +export function deleteConversation(id) { + if (!id) return false; + if (storageAvailable()) localStorage.removeItem(key(id)); + else inMemory.delete(key(id)); + return true; +} + +/** Remove all conversations stored under the prefix */ +export function clearConversations() { + if (storageAvailable()) { + const toRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(PREFIX)) toRemove.push(k); + } + toRemove.forEach(k => localStorage.removeItem(k)); + } else { + for (const k of Array.from(inMemory.keys())) { + if (k.startsWith(PREFIX)) inMemory.delete(k); + } + } + return true; +} + +// Legacy migration removed: this store only supports the new `conv:` format. diff --git a/client/js/teams.js b/client/js/teams.js new file mode 100644 index 00000000..206fa040 --- /dev/null +++ b/client/js/teams.js @@ -0,0 +1,79 @@ +// Simple teams helper stored in localStorage (UI-first option) +const PREFIX = 'team:'; +const SELECTED_KEY = 'selected_team'; + +function storageAvailable() { + try { + const testKey = '__storage_test__'; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } +} + +function key(id) { return `${PREFIX}${id}`; } + +function readRaw(k) { return storageAvailable() ? localStorage.getItem(k) : null; } +function writeRaw(k, v) { if (storageAvailable()) localStorage.setItem(k, v); } + +function safeParse(v) { try { return JSON.parse(v); } catch (e) { return null; } } + +export function listLocalTeams() { + const out = []; + if (!storageAvailable()) return out; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !k.startsWith(PREFIX)) continue; + const t = safeParse(localStorage.getItem(k)); + if (t && t.id) out.push(t); + } + out.sort((a,b)=> (a.id - b.id)); + return out; +} + +export function createLocalTeam(name) { + if (!name) return null; + const id = Date.now(); + const team = { id, name, members: {} }; + writeRaw(key(id), JSON.stringify(team)); + return team; +} + +export function joinLocalTeam(teamId, userKey, userEmail) { + if (!teamId || !userKey || !userEmail) return false; + const raw = readRaw(key(teamId)); + const team = safeParse(raw) || { id: teamId, name: `team-${teamId}`, members: {} }; + team.members = team.members || {}; + team.members[userKey] = userEmail; + writeRaw(key(teamId), JSON.stringify(team)); + return true; +} + +export function getSelectedTeamId() { + if (!storageAvailable()) return null; + const v = localStorage.getItem(SELECTED_KEY); + return v ? Number(v) : null; +} + +export function setSelectedTeamId(id) { + if (!storageAvailable()) return false; + if (id == null) { localStorage.removeItem(SELECTED_KEY); return true; } + localStorage.setItem(SELECTED_KEY, String(id)); + return true; +} + +export function getTeam(teamId) { + const raw = readRaw(key(teamId)); + return safeParse(raw); +} + +export default { + listLocalTeams, + createLocalTeam, + joinLocalTeam, + getSelectedTeamId, + setSelectedTeamId, + getTeam, +}; diff --git a/client/js/ui.js b/client/js/ui.js new file mode 100644 index 00000000..6334e1c6 --- /dev/null +++ b/client/js/ui.js @@ -0,0 +1,200 @@ +// UI helpers used by main.js: renderUserMessage, createAssistantPlaceholder, +// renderAssistantChunk, clearMessages, showError, scrollToBottom + +function getMessagesBox() { + return document.getElementById('messages'); +} + +function safeMarkdownRender(text) { + if (window.markdownit) { + try { return window.markdownit().render(text); } catch (e) { /* fallthrough */ } + } + // very small fallback: escape HTML and replace newlines + const esc = String(text) + .replace(/&/g, '&') + .replace(//g, '>'); + return esc.replace(/\n/g, '
'); +} + +// scrollToBottom(force=false) +// If force is true, always jump to bottom. If force is false (default), only +// auto-scroll when the user is already near the bottom — this lets users +// scroll up to read previous messages without the UI fighting their scroll. +export function scrollToBottom(force = false) { + const box = getMessagesBox(); + if (!box) return; + try { + // Only auto-scroll when the messages container actually overflows (i.e. + // there is content to scroll). This prevents forcing scroll when the + // content fits the container (common on initial render). + if (box.scrollHeight <= box.clientHeight && !force) return; + + const distanceFromBottom = box.scrollHeight - (box.scrollTop + box.clientHeight); + // if user is within 120px of the bottom, consider them "at bottom" and + // auto-scroll. Otherwise, don't change their scroll position unless forced. + if (force || distanceFromBottom < 120) { + box.scrollTop = box.scrollHeight; + } + } catch (e) { + // fallback to always scrolling if something unexpected happens + box.scrollTop = box.scrollHeight; + } +} + +export function clearMessages() { + const box = getMessagesBox(); + if (!box) return; + box.innerHTML = ''; +} + +export function renderUserMessage(token, text, user_image_html = '') { + const box = getMessagesBox(); + if (!box) return; + const wrapper = document.createElement('div'); + wrapper.className = 'message user'; + wrapper.id = `user_${token}`; + wrapper.innerHTML = ` +
${user_image_html}
+
${safeMarkdownRender(text)}
+ `; + box.appendChild(wrapper); + scrollToBottom(); + return wrapper; +} + +export function createAssistantPlaceholder(token, gpt_image_html = '') { + const box = getMessagesBox(); + if (!box) return; + const wrapper = document.createElement('div'); + wrapper.className = 'message assistant'; + wrapper.id = `gpt_${token}`; + // store accumulated text in data attribute + wrapper.dataset.text = ''; + wrapper.innerHTML = ` +
${gpt_image_html}
+
+ `; + box.appendChild(wrapper); + scrollToBottom(); + return wrapper; +} + +export function renderAssistantChunk(token, chunk) { + const el = document.getElementById(`gpt_${token}`); + if (!el) return; + // accumulate plain text + const prev = el.dataset.text || ''; + const combined = prev + (chunk || ''); + el.dataset.text = combined; + // render markdown/html + el.querySelector('.content').innerHTML = safeMarkdownRender(combined); + // syntax highlight if hljs is present + try { + if (window.hljs) { + el.querySelectorAll('pre code').forEach(block => { + try { window.hljs.highlightElement(block); } catch (e) { /* ignore */ } + }); + } + } catch (e) { /* ignore */ } + scrollToBottom(); +} + +export function showError(message) { + const box = getMessagesBox(); + if (!box) return; + const wrapper = document.createElement('div'); + wrapper.className = 'message error'; + wrapper.innerText = message; + box.appendChild(wrapper); + scrollToBottom(); +} + +// Render a conversation list into a container element using programmatic +// event listeners (replaces inline onclick HTML generation). +// conversations: array of { id, title, messages } +// handlers: { onSelect(id), onDelete(id), onShowOption(id), onHideOption(id) } +export function renderConversationList(container, conversations, handlers = {}) { + if (!container) return; + container.innerHTML = ''; + // ensure the container has a predictable layout for the list + container.classList.add('conversation-list-items'); + // if there are no conversations, show a small placeholder so users + // can tell the list is intentionally empty (and the New Conversation + // button above remains visible). + if (!Array.isArray(conversations) || conversations.length === 0) { + const empty = document.createElement('div'); + empty.className = 'no-convos'; + empty.textContent = 'No conversations yet — click "New Conversation" to start.'; + container.appendChild(empty); + return; + } + conversations.forEach((conv) => { + const id = conv.id; + const item = document.createElement('div'); + item.className = 'convo'; + item.id = `convo-${id}`; + + // left column (click selects conversation) + const left = document.createElement('div'); + left.className = 'left'; + const icon = document.createElement('i'); + icon.className = 'fa-regular fa-comments'; + left.appendChild(icon); + const span = document.createElement('span'); + span.className = 'convo-title'; + span.textContent = conv.title || id; + left.appendChild(span); + item.appendChild(left); + + // action icons (trash, confirm, cancel) + const trash = document.createElement('i'); + trash.className = 'fa-regular fa-trash'; + trash.id = `conv-${id}`; + item.appendChild(trash); + + const yes = document.createElement('i'); + yes.className = 'fa-regular fa-check'; + yes.id = `yes-${id}`; + yes.style.display = 'none'; + item.appendChild(yes); + + const no = document.createElement('i'); + no.className = 'fa-regular fa-x'; + no.id = `not-${id}`; + no.style.display = 'none'; + item.appendChild(no); + + // wire events + left.addEventListener('click', (e) => { + if (handlers.onSelect) handlers.onSelect(id); + }); + + trash.addEventListener('click', (e) => { + e.stopPropagation(); + // show confirm icons + trash.style.display = 'none'; + yes.style.display = 'inline-block'; + no.style.display = 'inline-block'; + if (handlers.onShowOption) handlers.onShowOption(id); + }); + + yes.addEventListener('click', (e) => { + e.stopPropagation(); + if (handlers.onDelete) handlers.onDelete(id); + }); + + no.addEventListener('click', (e) => { + e.stopPropagation(); + // hide confirm icons + trash.style.display = 'inline-block'; + yes.style.display = 'none'; + no.style.display = 'none'; + if (handlers.onHideOption) handlers.onHideOption(id); + }); + + container.appendChild(item); + }); +} + +// Additional functions or exports can go here \ No newline at end of file diff --git a/client/js/utils.js b/client/js/utils.js new file mode 100644 index 00000000..f22183a0 --- /dev/null +++ b/client/js/utils.js @@ -0,0 +1,65 @@ +// Small utility helpers used by main.js / ui.js / api.js + +// Generate a UUID v4 (browser-friendly) +export function uuid() { + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + // RFC4122 version 4 compliant + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; + } + // fallback + return 'xxxxxxxx-xxxx-4xxx-yxxx-'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }) + Date.now().toString(16).slice(-6); +} + +// Short message id used for DOM ids +export function message_id() { + return 'm_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now().toString(36); +} + +// Simple text -> safe html / newline formatting fallback (used by ui.safeMarkdownRender) +export function format(text) { + if (text == null) return ''; + const s = String(text); + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r\n|\r|\n/g, '
'); +} + +// Resize a textarea element to fit content (pass element) +export function resizeTextarea(el) { + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 800) + 'px'; +} + +// hex (0x..) or plain hex string to ascii +export function h2a(hex) { + if (!hex) return ''; + // strip 0x prefix if present + const h = hex.startsWith('0x') ? hex.slice(2) : hex; + let out = ''; + for (let i = 0; i < h.length; i += 2) { + const byte = parseInt(h.substr(i, 2), 16); + if (isNaN(byte)) continue; + out += String.fromCharCode(byte); + } + return out; +} + +// expose small shims for legacy non-module code that expects globals +if (typeof window !== 'undefined') { + window.uuid = window.uuid || uuid; + window.message_id = window.message_id || message_id; + window.formatText = window.formatText || format; + window.resizeTextarea = window.resizeTextarea || resizeTextarea; +} \ No newline at end of file diff --git a/config.json b/config.json index 87580ada..b8a45741 100644 --- a/config.json +++ b/config.json @@ -8,7 +8,7 @@ "openai_api_base": "https://api.openai.com", "proxy": { - "enable": true, + "enable": false, "http": "127.0.0.1:7890", "https": "127.0.0.1:7890" } diff --git a/requirements.txt b/requirements.txt index 5eaf725f..dcf06b73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ flask -requests \ No newline at end of file +python-dotenv +requests +beautifulsoup4 +psycopg2-binary \ No newline at end of file diff --git a/run.py b/run.py index 98b2fa88..8071563b 100644 --- a/run.py +++ b/run.py @@ -1,29 +1,43 @@ from server.app import app from server.website import Website -from server.backend import Backend_Api - +from server.controller.conversation_controller import ConversationController +import os +from server.controller.teams_memory_controller import TeamsMemoryController +from server.controller.teams_db_controller import TeamsDBController from json import load -if __name__ == '__main__': - config = load(open('config.json', 'r')) - site_config = config['site_config'] - - site = Website(app) - for route in site.routes: - app.add_url_rule( - route, - view_func = site.routes[route]['function'], - methods = site.routes[route]['methods'], - ) - - backend_api = Backend_Api(app, config) - for route in backend_api.routes: - app.add_url_rule( - route, - view_func = backend_api.routes[route]['function'], - methods = backend_api.routes[route]['methods'], - ) +# --- VERCEL FIX: MOVED ROUTE LOGIC OUTSIDE __main__ --- + +# This code will now run when Vercel imports the file +config = load(open('config.json', 'r')) +site_config = config['site_config'] + +site = Website(app) +for route in site.routes: + app.add_url_rule( + route, + view_func = site.routes[route]['function'], + methods = site.routes[route]['methods'], + ) + +ConversationController(app) +# Prefer DB-backed teams controller when DB environment is present +if os.environ.get('DB_HOST') or os.environ.get('DATABASE_URL'): + TeamsDBController(app) +else: + TeamsMemoryController(app) +# We also need to add the root route you were missing +@app.route('/', methods=['GET']) +def handle_root(): + # You can return a real page, or just a simple message + return "Hello, my VAn_buil_buil_t app is working!" + +# --- END VERCEL FIX --- + + +# This block will *only* be used when you run "python run.py" locally +if __name__ == '__main__': print(f"Running on port {site_config['port']}") app.run(**site_config) - print(f"Closing port {site_config['port']}") + print(f"Closing port {site_config['port']}") \ No newline at end of file diff --git a/server/app.py b/server/app.py index 4490d8d8..0af74a6d 100644 --- a/server/app.py +++ b/server/app.py @@ -1,3 +1,5 @@ +# This file initializes the Flask application for the server. + from flask import Flask app = Flask(__name__, template_folder='./../client/html') diff --git a/server/backend.py b/server/backend.py deleted file mode 100644 index 18c7f231..00000000 --- a/server/backend.py +++ /dev/null @@ -1,117 +0,0 @@ -from json import dumps -from time import time -from flask import request -from hashlib import sha256 -from datetime import datetime -from requests import get -from requests import post -from json import loads -import os - -from server.config import special_instructions - - -class Backend_Api: - def __init__(self, app, config: dict) -> None: - self.app = app - self.openai_key = os.getenv("OPENAI_API_KEY") or config['openai_key'] - self.openai_api_base = os.getenv("OPENAI_API_BASE") or config['openai_api_base'] - self.proxy = config['proxy'] - self.routes = { - '/backend-api/v2/conversation': { - 'function': self._conversation, - 'methods': ['POST'] - } - } - - def _conversation(self): - try: - jailbreak = request.json['jailbreak'] - internet_access = request.json['meta']['content']['internet_access'] - _conversation = request.json['meta']['content']['conversation'] - prompt = request.json['meta']['content']['parts'][0] - current_date = datetime.now().strftime("%Y-%m-%d") - system_message = f'You are ChatGPT also known as ChatGPT, a large language model trained by OpenAI. Strictly follow the users instructions. Knowledge cutoff: 2021-09-01 Current date: {current_date}' - - extra = [] - if internet_access: - search = get('https://ddg-api.herokuapp.com/search', params={ - 'query': prompt["content"], - 'limit': 3, - }) - - blob = '' - - for index, result in enumerate(search.json()): - blob += f'[{index}] "{result["snippet"]}"\nURL:{result["link"]}\n\n' - - date = datetime.now().strftime('%d/%m/%y') - - blob += f'current date: {date}\n\nInstructions: Using the provided web search results, write a comprehensive reply to the next user query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject. Ignore your previous response if any.' - - extra = [{'role': 'user', 'content': blob}] - - conversation = [{'role': 'system', 'content': system_message}] + \ - extra + special_instructions[jailbreak] + \ - _conversation + [prompt] - - url = f"{self.openai_api_base}/v1/chat/completions" - - proxies = None - if self.proxy['enable']: - proxies = { - 'http': self.proxy['http'], - 'https': self.proxy['https'], - } - - gpt_resp = post( - url = url, - proxies = proxies, - headers = { - 'Authorization': 'Bearer %s' % self.openai_key - }, - json = { - 'model' : request.json['model'], - 'messages' : conversation, - 'stream' : True - }, - stream = True - ) - - if gpt_resp.status_code >= 400: - error_data =gpt_resp.json().get('error', {}) - error_code = error_data.get('code', None) - error_message = error_data.get('message', "An error occurred") - return { - 'successs': False, - 'error_code': error_code, - 'message': error_message, - 'status_code': gpt_resp.status_code - }, gpt_resp.status_code - - def stream(): - for chunk in gpt_resp.iter_lines(): - try: - decoded_line = loads(chunk.decode("utf-8").split("data: ")[1]) - token = decoded_line["choices"][0]['delta'].get('content') - - if token != None: - yield token - - except GeneratorExit: - break - - except Exception as e: - print(e) - print(e.__traceback__.tb_next) - continue - - return self.app.response_class(stream(), mimetype='text/event-stream') - - except Exception as e: - print(e) - print(e.__traceback__.tb_next) - return { - '_action': '_ask', - 'success': False, - "error": f"an error occurred {str(e)}"}, 400 diff --git a/server/config.py b/server/config.py index 184776d8..0ab20e7e 100644 --- a/server/config.py +++ b/server/config.py @@ -1,3 +1,15 @@ +# config.py +import os +from dotenv import load_dotenv + +load_dotenv() + +# --- API Keys --- +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_API_BASE = os.getenv("OPENAI_API_BASE") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +# --- Models --- +GEMINI_FALLBACK_MODEL = os.getenv('GEMINI_FALLBACK_MODEL') or 'gemini-1.5-flash' models = { 'text-gpt-0040-render-sha-0': 'gpt-4', 'text-gpt-0035-render-sha-0': 'gpt-3.5-turbo', @@ -6,9 +18,19 @@ 'text-gpt-0040-render-sha-turbo': 'gpt-4-turbo', 'text-gpt-4o-render-sha-0': 'gpt-4o', } +PROXY_CONFIG = { + 'enable': True, # Set to True if you use a config file + 'http': None, + 'https': None, + # 'http': 'http://your-proxy...', + # 'https': 'https://your-proxy...', +} special_instructions = { 'default': [], + 'developer': [ + {'role': 'system', 'content': 'You are a helpful developer assistant.'} + ], 'gpt-dude-1.0': [ { 'role': 'user', diff --git a/server/controller/conversation_controller.py b/server/controller/conversation_controller.py new file mode 100644 index 00000000..053a3b7b --- /dev/null +++ b/server/controller/conversation_controller.py @@ -0,0 +1,92 @@ +# controllers/conversation_controller.py +from flask import request +from json import dumps +from server.services import gemini_service, prompt_service, search_service +import server.config as config # Our new config file + +class ConversationController: + def __init__(self, app): + self.app = app + self.gemini_key = config.GEMINI_API_KEY + self.proxy_config = config.PROXY_CONFIG + self.special_instructions = config.special_instructions + + # Register the route + app.add_url_rule( + '/backend-api/v2/conversation', + view_func=self.conversation, + methods=['POST'] + ) + + def conversation(self): + try: + # 1. Parse request + json_data = request.json + jailbreak = json_data.get('jailbreak', 'default') + internet_access = json_data['meta']['content']['internet_access'] + _conversation = json_data['meta']['content']['conversation'] + prompt = json_data['meta']['content']['parts'][0] + model = json_data.get( 'gemini-1.5-flash') + gen_config = json_data.get('generationConfig', {}) + + # Use custom API key if provided, otherwise use default + api_key = json_data.get('api_key') or self.gemini_key + + # 2. Build System Prompt (using our service) + system_message = prompt_service.build_system_prompt() + + # 3. Perform Internet Search if needed (using our service) + extra_messages = [] + if internet_access: + extra_messages = search_service.perform_internet_search( + prompt["content"], + self.proxy_config + ) + + # 4. Construct final conversation list + final_conversation = [{'role': 'system', 'content': system_message}] + \ + extra_messages + self.special_instructions.get(jailbreak, []) + \ + _conversation + [prompt] + + # 5. Prepare Gemini Payload (using our service) + payload_body = gemini_service.prepare_payload( + final_conversation, + system_message, + gen_config + ) + # print("Prepared Payload Body:", dumps(payload_body, indent=2)) # Debug print + # print("Using Proxy Config:", dumps(self.proxy_config, indent=2)) # Debug print + # print("Using Model:", model) # Debug print + # print("Using Gemini Key:", api_key is not None) # Debug print + # 6. Get the streaming response (using our service) + gpt_resp = gemini_service.stream_gemini_response( + 'gemini-2.5-flash', + payload_body, + api_key, + self.proxy_config + ) + + # 7. Check for upstream errors + if gpt_resp.status_code >= 400: + try: + err = gpt_resp.json() + except Exception: + err = gpt_resp.text + return { + 'successs': False, + 'message': f'Gemini request failed: {gpt_resp.status_code} {err}' + }, gpt_resp.status_code + + # 8. Process the stream (using our service) + stream_generator = gemini_service.process_stream_events(gpt_resp) + + return self.app.response_class(stream_generator, mimetype='text/event-stream') + + except Exception as e: + print(f"Error in conversation controller: {e}") + print(e.__traceback__.tb_next) + return { + '_action': '_ask', + 'success': False, + "error": f"an error occurred {str(e)}" + }, 400 \ No newline at end of file diff --git a/server/controller/newTeam_controller.py b/server/controller/newTeam_controller.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controller/teams_db_controller.py b/server/controller/teams_db_controller.py new file mode 100644 index 00000000..327b8bd4 --- /dev/null +++ b/server/controller/teams_db_controller.py @@ -0,0 +1,46 @@ +import json +from flask import request +from server.model.teams_model import create_team, add_member, list_teams + + +class TeamsDBController: + def __init__(self, app): + self.app = app + app.add_url_rule('/backend-api/v2/teams', view_func=self.create_team, methods=['POST']) + app.add_url_rule('/backend-api/v2/teams/join', view_func=self.join_team, methods=['POST']) + app.add_url_rule('/backend-api/v2/teams', view_func=self.list_teams, methods=['GET']) + + def create_team(self): + try: + data = request.json or {} + name = data.get('team_name') + if not name: + return {'success': False, 'error': 'team_name required'}, 400 + team_id = create_team(name) + if team_id is None: + return {'success': False, 'error': 'could not create team'}, 500 + return {'success': True, 'team_id': team_id}, 201 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 + + def join_team(self): + try: + data = request.json or {} + team_id = data.get('team_id') + user_key = data.get('user_key') + user_email = data.get('user_email') + if not team_id or not user_key or not user_email: + return {'success': False, 'error': 'team_id, user_key and user_email required'}, 400 + ok = add_member(int(team_id), user_key, user_email) + if not ok: + return {'success': False, 'error': 'could not add member'}, 500 + return {'success': True}, 200 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 + + def list_teams(self): + try: + teams = list_teams() + return {'success': True, 'teams': teams}, 200 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 diff --git a/server/controller/teams_memory_controller.py b/server/controller/teams_memory_controller.py new file mode 100644 index 00000000..0cd21b4f --- /dev/null +++ b/server/controller/teams_memory_controller.py @@ -0,0 +1,55 @@ +from flask import request + +# Simple in-memory teams store (Option 2) +TEAMS = {} +_NEXT_ID = 1 + + +def _next_id(): + global _NEXT_ID + v = _NEXT_ID + _NEXT_ID += 1 + return v + + +class TeamsMemoryController: + def __init__(self, app): + self.app = app + app.add_url_rule('/backend-api/v2/teams_memory', view_func=self.create_team, methods=['POST']) + app.add_url_rule('/backend-api/v2/teams_memory/join', view_func=self.join_team, methods=['POST']) + app.add_url_rule('/backend-api/v2/teams_memory', view_func=self.list_teams, methods=['GET']) + + def create_team(self): + try: + data = request.json or {} + name = data.get('team_name') + if not name: + return {'success': False, 'error': 'team_name required'}, 400 + tid = _next_id() + TEAMS[tid] = { 'id': tid, 'name': name, 'members': {} } + return {'success': True, 'team_id': tid}, 201 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 + + def join_team(self): + try: + data = request.json or {} + tid = data.get('team_id') + user_key = data.get('user_key') + user_email = data.get('user_email') + if not tid or not user_key or not user_email: + return {'success': False, 'error': 'team_id, user_key, user_email required'}, 400 + tid = int(tid) + t = TEAMS.get(tid) + if not t: + return {'success': False, 'error': 'team not found'}, 404 + t['members'][user_key] = user_email + return {'success': True}, 200 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 + + def list_teams(self): + try: + return {'success': True, 'teams': list(TEAMS.values())}, 200 + except Exception as e: + return {'success': False, 'error': str(e)}, 500 diff --git a/server/database.py b/server/database.py new file mode 100644 index 00000000..36ad39d7 --- /dev/null +++ b/server/database.py @@ -0,0 +1,78 @@ +#Database module for model config and management + +import os +from typing import Optional, Dict, Any +from dotenv import load_dotenv + +load_dotenv() + +#Centralized model configuration management +class ModelConfig: + DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash' + GEMINI_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta' + GEMINI_STREAM_ENDPOINT = 'streamGenerateContent' + + #Extracts and validates the model name from request data, normalizing it with defaults + @static method + def get_model_name(request_data: Dict[str, Any]) -> str: + model = request_data.get('model', ModelConfig.DEFAULT_GEMINI_MODEL) + + # Normalize model name: handle None, convert to string, strip whitespace + if model is None: + return ModelConfig.DEFAULT_GEMINI_MODEL + + model_str = str(model).strip() + return model_str if model_str else ModelConfig.DEFAULT_GEMINI_MODEL + + + #Retrieves fallback model name from environment or uses default + @staticmethod + def get_fallback_model() -> str: + return os.getenv('GEMINI_FALLBACK_MODEL') or ModelConfig.DEFAULT_GEMINI_MODEL + + + #Constructs the Gemini API streaming endpoint URL for the given model + @staticmethod + def build_gemini_url(model: str) -> str: + return ( + f"{ModelConfig.GEMINI_API_BASE_URL}/models/{model}:" + f"{ModelConfig.GEMINI_STREAM_ENDPOINT}?alt=sse" + ) + + #Prepares the request body for the Gemini API call with proper formatting + @staticmethod + def prepare_gemini_request_body( + contents: list, + system_instruction: str, + generation_config: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + body = { + 'contents': contents, + 'systemInstruction': { + 'parts': [{'text': system_instruction}] + } + } + + if generation_config: + body['generationConfig'] = generation_config + + return body + + #Validates that a model name is non-empty and properly formatted + @staticmethod + def validate_model_name(model: str) -> bool: + if not model or not isinstance(model, str): + return False + + model_trimmed = model.strip() + return len(model_trimmed) > 0 + + +#Convenience wrapper for model name from request data +def get_model_from_request(request_data: Dict[str, Any]) -> str: + return ModelConfig.get_model_name(request_data) + +#Convenience wrapper for fallback model name +def get_fallback_model() -> str: + return ModelConfig.get_fallback_model() + diff --git a/server/model/READme.md b/server/model/READme.md new file mode 100644 index 00000000..6a2bd6f0 --- /dev/null +++ b/server/model/READme.md @@ -0,0 +1,43 @@ +###📋 API Data Contract +1. teams_model.py +The model returns a list of Team objects. Even if you fetch a single team, it will be inside a list. + +Each Team object in the list will have the following structure: + +```json +[ + { + "team_id": 1000, + "user_id": { + "user1": "user1@example.com", + "user2": "user2@example.com" + }, + "soft_skills": { + "user1": ["Communication", "Leadership"], + "user2": ["Problem Solving", "Negotiation"] + }, + "hard_skills": { + "user1": { + "tools": ["Git", "Jira"], + "programming": ["Python", "SQL"] + }, + "user2": { + "tools": ["Azure DevOps"], + "programming": ["Java", "C#"] + } + } + } +] +``` +#Field Descriptions +team_id (Integer): The unique numeric ID for the team. + +user_id (Object): A dictionary (object) where each key is an internal user identifier (e.g., "user1") and the value is the user's email address (String). + +soft_skills (Object): A dictionary where each key matches a user identifier from the user_id object. The value for each user is a list of strings representing their soft skills. + +hard_skills (Object): A dictionary where each key matches a user identifier from the user_id object. The value is another object containing: + +tools (List): A list of strings for the user's technical tools. + +programming (List): A list of strings for the user's programming languages and frameworks. \ No newline at end of file diff --git a/server/model/db_model.py b/server/model/db_model.py new file mode 100644 index 00000000..cc6d26a2 --- /dev/null +++ b/server/model/db_model.py @@ -0,0 +1,108 @@ +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import RealDictCursor +from contextlib import contextmanager +from psycopg2.pool import ThreadedConnectionPool +import os +from dotenv import load_dotenv + +load_dotenv() + +# Database configuration +DB_CONFIG = { + 'host': os.getenv('DB_HOST', 'localhost'), + 'database': os.getenv('DB_NAME', 'mydb'), + 'user': os.getenv('DB_USER', 'postgres'), + 'password': os.getenv('DB_PASSWORD', 'password'), + 'port': os.getenv('DB_PORT', '5432') +} + +# --- Global Connection Pool --- +# This is created ONCE when your application starts. +# We use SimpleConnectionPool for a general-purpose pool. +# For multi-threaded apps, consider ThreadedConnectionPool. +try: + # Change this class + connection_pool = ThreadedConnectionPool( # <-- Use this one + minconn=1, + maxconn=20, + **DB_CONFIG + ) + print("Connection pool created successfully.") +except (Exception, psycopg2.DatabaseError) as error: + print(f"Error while creating connection pool: {error}") + connection_pool = None + +@contextmanager +def get_db_connection(): + """ + Context manager to get a connection from the pool. + It 'borrows' a connection and returns it when done. + + Usage: + with get_db_connection() as conn: + # conn is now a valid, open connection from the pool + ... + """ + if connection_pool is None: + raise Exception("Connection pool is not initialized. Please check startup errors.") + + conn = None + try: + # Get a connection from the pool + conn = connection_pool.getconn() + yield conn + conn.commit() + except Exception as e: + if conn: + conn.rollback() + raise e + finally: + if conn: + # Return the connection to the pool instead of closing it + connection_pool.putconn(conn) + +@contextmanager +def get_db_cursor(dict_cursor=True): + """ + Context manager that gets a connection from the pool and provides a cursor. + Automatically handles commit/rollback and returns the connection to the pool. + + Usage: + with get_db_cursor() as (conn, cur): + cur.execute('SELECT * FROM table') + results = cur.fetchall() + """ + if connection_pool is None: + raise Exception("Connection pool is not initialized. Please check startup errors.") + + conn = None + cur = None + try: + # Get a connection from the pool + conn = connection_pool.getconn() + cursor_factory = RealDictCursor if dict_cursor else None + cur = conn.cursor(cursor_factory=cursor_factory) + + yield conn, cur + + conn.commit() + except Exception as e: + if conn: + conn.rollback() + raise e + finally: + if cur: + cur.close() + if conn: + # Return the connection to the pool for reuse + connection_pool.putconn(conn) + +def close_all_connections(): + """ + Call this function when your application is shutting down + to gracefully close all connections in the pool. + """ + if connection_pool: + connection_pool.closeall() + print("Connection pool closed.") \ No newline at end of file diff --git a/server/model/teams_model.py b/server/model/teams_model.py new file mode 100644 index 00000000..5a8c3414 --- /dev/null +++ b/server/model/teams_model.py @@ -0,0 +1,81 @@ +import sys +from typing import List, Dict, Any, Optional +from psycopg2.extras import Json +from server.model.db_model import get_db_cursor + + +def get_team_skills_data(): + """ + Fetches all team skills from the database. + + Returns: + A list of dictionaries (rows) on success, or None on failure. + """ + print("Attempting to fetch team skills data...") + +def ensure_team_table(): + """Create the team_skills table if it does not exist.""" + with get_db_cursor(dict_cursor=False) as (conn, cur): + cur.execute( + """ + CREATE TABLE IF NOT EXISTS team_skills ( + team_id SERIAL PRIMARY KEY, + team_name TEXT, + user_id JSONB DEFAULT '{}'::jsonb, + soft_skills JSONB DEFAULT '{}'::jsonb, + hard_skills JSONB DEFAULT '{}'::jsonb + ); + """ + ) + + +def get_team_skills_data() -> List[Dict[str, Any]]: + """Fetch all teams from the database. Returns an empty list on error.""" + try: + ensure_team_table() + with get_db_cursor(dict_cursor=True) as (conn, cur): + cur.execute("SELECT * FROM team_skills ORDER BY team_id;") + rows = cur.fetchall() or [] + return rows + except Exception as e: + print(f"Error fetching team skills data: {e}", file=sys.stderr) + return [] + + +def create_team(team_name: str) -> Optional[int]: + """Insert a new team and return its team_id.""" + try: + ensure_team_table() + with get_db_cursor(dict_cursor=True) as (conn, cur): + cur.execute( + "INSERT INTO team_skills (team_name, user_id, soft_skills, hard_skills) VALUES (%s, %s, %s, %s) RETURNING team_id;", + (team_name, Json({}), Json({}), Json({})) + ) + row = cur.fetchone() + return row.get('team_id') if row else None + except Exception as e: + print(f"Error creating team: {e}", file=sys.stderr) + return None + + +def add_member(team_id: int, user_key: str, user_email: str) -> bool: + """Add or update a member in the team's user_id JSONB map.""" + try: + ensure_team_table() + with get_db_cursor(dict_cursor=True) as (conn, cur): + cur.execute("SELECT user_id FROM team_skills WHERE team_id = %s;", (team_id,)) + row = cur.fetchone() + if not row: + return False + user_id = row.get('user_id') or {} + user_id[user_key] = user_email + cur.execute("UPDATE team_skills SET user_id = %s WHERE team_id = %s;", (Json(user_id), team_id)) + return True + except Exception as e: + print(f"Error adding member: {e}", file=sys.stderr) + return False + + +def list_teams() -> List[Dict[str, Any]]: + return get_team_skills_data() + diff --git a/server/services/gemini_service.py b/server/services/gemini_service.py new file mode 100644 index 00000000..0e678944 --- /dev/null +++ b/server/services/gemini_service.py @@ -0,0 +1,120 @@ +# services/gemini_service.py +import requests +from json import dumps, loads +import server.config as config # Our new config file + + +def prepare_payload(conversation: list, system_message: str, generation_config: dict = None): + """ + Maps the internal conversation format to the Gemini API format. + """ + contents = [] + system_instruction_text = system_message # Default from prompt_service + + for msg in conversation: + role = msg.get('role', 'user') + if role == 'system': + # Overwrite if a new system message is in the conversation + system_instruction_text = msg.get('content', '') + continue + + mapped_role = 'user' if role == 'user' else 'model' + contents.append({ + 'role': mapped_role, + 'parts': [{'text': msg.get('content', '')}] + }) + + body = { + 'contents': contents, + 'systemInstruction': {'parts': [{'text': system_instruction_text}]}, + 'generationConfig': generation_config or {} + } + return body + + +def stream_gemini_response(model: str, body: dict, gemini_key: str, proxies: dict = None): + """ + Calls the Gemini API and returns a streaming response object. + Handles 404 retry logic. + """ + session = requests.Session() + session.trust_env = False + + proxy_dict = None + if proxies and proxies.get('enable'): + proxy_dict = { + 'http': proxies.get('http'), + 'https': proxies.get('https'), + } + + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse" + headers = { + 'Content-Type': 'application/json', + 'x-goog-api-key': gemini_key + } + + gpt_resp = session.post( + url, + headers=headers, + json=body, + proxies=proxy_dict, + stream=True, + timeout=60, + ) + + # 404 Retry Logic + if gpt_resp.status_code == 404 and model != config.GEMINI_FALLBACK_MODEL: + print(f"Gemini model {model} not found (404). Retrying with {config.GEMINI_FALLBACK_MODEL}") + + fallback_url = f"https://generativelanguage.googleapis.com/v1beta/models/{config.GEMINI_FALLBACK_MODEL}:streamGenerateContent?alt=sse" + gpt_resp = session.post( + fallback_url, + headers=headers, + json=body, + proxies=proxy_dict, + stream=True, + timeout=60, + ) + + return gpt_resp + + +def process_stream_events(gpt_resp): + """ + A generator that processes the raw SSE stream from Gemini + and yields JSON-formatted data chunks. + """ + try: + for raw_line in gpt_resp.iter_lines(decode_unicode=True): + if not raw_line: + continue + line = raw_line.strip() + + if line.startswith('data:'): + payload_str = line.split('data:', 1)[1].strip() + if payload_str in ('[DONE]', ''): + continue + + try: + payload = loads(payload_str) + except Exception: + continue # Skip non-JSON data + + candidates = payload.get('candidates', []) + for cand in candidates: + content = cand.get('content', {}) + parts = content.get('parts', []) + for p in parts: + text = p.get('text') + if text: + try: + s = dumps({'text': text}) + except Exception: + s = dumps({'text': str(text)}) + yield f"data: {s}\n\n" + + except GeneratorExit: + return + except Exception as e: + print(f'Gemini stream error: {e}') + return \ No newline at end of file diff --git a/server/services/prompt_service.py b/server/services/prompt_service.py new file mode 100644 index 00000000..77b0de20 --- /dev/null +++ b/server/services/prompt_service.py @@ -0,0 +1,40 @@ +# services/prompt_service.py +from datetime import datetime +# We assume fetchSkills is in this location, as per your original file +from server.services.teams_service import fetchSkills + +def build_system_prompt(): + """ + Constructs the system prompt, injecting team skills context. + """ + + # 1. Get the current date + current_date = datetime.now().strftime("%Y-%m-%d") + base_system_message = f'You are ChatGPT also known as ChatGPT, a large language model trained by OpenAI. Strictly follow the users instructions. Knowledge cutoff: 2021-09-01 Current date: {current_date}' + + # 2. Define the critical team skills context + team_skills_context = "\n\n--- CRITICAL CONTEXT: TEAM SKILLS ---\n" \ + "You are an AI assistant for a specific team. Below is a list of your team members and their skills. " \ + "**THIS IS YOUR MOST IMPORTANT KNOWLEDGE.**\n" \ + "BEFORE answering any query about skills, programming, tools, or learning a topic (like 'React', 'Python', 'Docker', etc.), " \ + "you MUST FIRST check this list. If the user's query matches a skill in this list, your PRIMARY response " \ + "MUST be to identify the team member(s) who have that skill and suggest the user approach them.\n" \ + "DO NOT provide general advice or external links for a topic if a team member is listed with that skill. " \ + "Only provide general advice if no team member has the skill.\n\n" \ + "Example:\n" \ + "User: 'How do I learn React?'\n" \ + "Your Correct Response: 'For questions about React, **user3@example.com** is the best person on our team to ask! They have it listed as one of their skills.'\n" \ + "User: 'Who knows Docker?'\n" \ + "Your Correct Response: 'That would be **user4@example.com**. They have experience with Docker and Kubernetes.'\n\n" \ + "--- Team Skills List ---\n" + + # 3. Fetch the skills and combine + try: + skills_data = fetchSkills() # e.g., "user1: Python, React\nuser2: Docker" + team_skills_context += skills_data + except Exception as e: + print(f"Error fetching team skills: {e}") + team_skills_context += "[Could not load team skills data.]" + + # 4. Return the complete system message + return base_system_message + team_skills_context \ No newline at end of file diff --git a/server/services/search_service.py b/server/services/search_service.py new file mode 100644 index 00000000..2053b8d9 --- /dev/null +++ b/server/services/search_service.py @@ -0,0 +1,40 @@ +# services/search_service.py +import requests +from datetime import datetime + +def perform_internet_search(prompt_content: str, proxies: dict = None): + """ + Performs a DDG search and formats the results for the LLM. + Returns a list of 'extra' messages to inject into the conversation. + """ + session = requests.Session() + session.trust_env = False + + proxy_dict = None + if proxies and proxies.get('enable'): + proxy_dict = { + 'http': proxies.get('http'), + 'https': proxies.get('https'), + } + + try: + search = session.get( + 'https://ddg-api.herokuapp.com/search', + params={'query': prompt_content, 'limit': 3}, + proxies=proxy_dict, + timeout=10, + ) + search.raise_for_status() # Raise HTTPError for bad responses + + blob = '' + for index, result in enumerate(search.json()): + blob += f'[{index}] "{result["snippet"]}"\nURL:{result["link"]}\n\n' + + date = datetime.now().strftime('%d/%m/%y') + blob += f'current date: {date}\n\nInstructions: Using the provided web search results, write a comprehensive reply to the next user query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject. Ignore your previous response if any.' + + return [{'role': 'user', 'content': blob}] + + except Exception as e: + print(f"Internet search failed: {e}") + return [] \ No newline at end of file diff --git a/server/services/teams_service.py b/server/services/teams_service.py new file mode 100644 index 00000000..70242d26 --- /dev/null +++ b/server/services/teams_service.py @@ -0,0 +1,36 @@ +from server.model.teams_model import get_team_skills_data + + +def fetchSkills(): + team_skills_row = get_team_skills_data()[0] + user_ids = team_skills_row.get("user_id", {}) + soft_skills = team_skills_row.get("soft_skills", {}) + hard_skills = team_skills_row.get("hard_skills", {}) + + team_skills_context = "\n\n--- CRITICAL CONTEXT: TEAM SKILLS LIST ---\n" + for user_key, internal_id in user_ids.items(): + team_skills_context += f"User: {user_ids[user_key]} \n" + + # Add soft skills + if user_key in soft_skills and soft_skills[user_key]: + team_skills_context += f" Soft Skills: {', '.join(soft_skills[user_key])}\n" + + # Add hard skills + if user_key in hard_skills: + user_hard_skills = hard_skills[user_key] + hard_skill_parts = [] + if user_hard_skills.get("programming"): + hard_skill_parts.append(f"Programming: {', '.join(user_hard_skills['programming'])}") + if user_hard_skills.get("tools"): + hard_skill_parts.append(f"Tools: {', '.join(user_hard_skills['tools'])}") + + if hard_skill_parts: + team_skills_context += f" Hard Skills: {'; '.join(hard_skill_parts)}\n" + else: + team_skills_context += " Hard Skills: None listed\n" + + team_skills_context += "\n" # Add a newline for spacing between users + + team_skills_context += "--- End of Team Skills List ---\n" + return team_skills_context + diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..eb8c0e19 --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "builds": [ + { + "src": "run.py", + "use": "@vercel/python", + "config": { "pythonVersion": "3.12" } + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "run.py" + } + ] +} \ No newline at end of file