diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..978792356 Binary files /dev/null and b/.DS_Store differ diff --git a/.cursor/rules/general-rule.mdc b/.cursor/rules/general-rule.mdc index 01ef0ac64..989407503 100644 --- a/.cursor/rules/general-rule.mdc +++ b/.cursor/rules/general-rule.mdc @@ -1,11 +1,15 @@ --- -description: -globs: +description: +globs: alwaysApply: true --- + ## Rules to Follow -- You must always commit your changes whenever you update code. +- You always prefer to use branch development. +- Before writing any code, you create a feature branch to hold those changes. +- After you are done, provide instructions in a "Merge.md" file that explains how to merge the changes back to main with both a Github PR route and a Github CLI route. +- You must always commit your changes whenever you update code. - You must always try and write code that is well documented. (self or commented is fine) - You must only work on a single feature at a time. -- You must explain your decisions thouroughly to the user. \ No newline at end of file +- You must explain your decisions thouroughly to the user. diff --git a/Merge.md b/Merge.md new file mode 100644 index 000000000..1ead438a8 --- /dev/null +++ b/Merge.md @@ -0,0 +1,123 @@ +# Deployment Instructions + +This project consists of two separate deployments: + +1. Frontend (Next.js) +2. API (FastAPI) + +## API Deployment + +### Prerequisites + +1. Install Vercel CLI: + +```bash +npm install -g vercel +``` + +2. Login to Vercel: + +```bash +vercel login +``` + +### Deploy API + +1. Navigate to the API directory: + +```bash +cd api +``` + +2. Deploy to Vercel: + +```bash +vercel +``` + +3. After deployment, copy the API URL (you'll need it for the frontend) + +### Environment Variables for API + +Set these in your Vercel project dashboard: + +- `OPENAI_API_KEY`: Your OpenAI API key + +## Frontend Deployment + +### Prerequisites + +Same as API deployment (Vercel CLI and login) + +### Deploy Frontend + +1. Navigate to the frontend directory: + +```bash +cd frontend +``` + +2. Deploy to Vercel: + +```bash +vercel +``` + +### Environment Variables for Frontend + +Set these in your Vercel project dashboard: + +- `NEXT_PUBLIC_API_URL`: The URL of your deployed API (e.g., https://your-api.vercel.app) + +## Monitoring and Management + +### API Project + +1. Monitor API logs and performance in Vercel dashboard +2. Check Function execution logs +3. Monitor API rate limits and usage + +### Frontend Project + +1. Monitor build logs and deployment status +2. Check static asset delivery +3. Monitor page performance + +## Troubleshooting + +### API Issues + +1. Check API logs in Vercel dashboard +2. Verify environment variables are set +3. Test API endpoints directly + +### Frontend Issues + +1. Check build logs +2. Verify API URL is correctly set +3. Check browser console for errors +4. Verify API is accessible from frontend domain + +## Alternative: GitHub PR Route + +1. For API changes: + +```bash +cd api +gh pr create --title "Deploy API changes" --body "Deploy latest API changes to Vercel" +``` + +2. For Frontend changes: + +```bash +cd frontend +gh pr create --title "Deploy Frontend changes" --body "Deploy latest Frontend changes to Vercel" +``` + +3. After PRs are merged: + +```bash +gh pr merge +``` + +Vercel will automatically deploy changes when merged to main branch for each project. diff --git a/aimakerspace/__init__.py b/aimakerspace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aimakerspace/aimakerspace b/aimakerspace/aimakerspace new file mode 120000 index 000000000..422fea040 --- /dev/null +++ b/aimakerspace/aimakerspace @@ -0,0 +1 @@ +/Users/amanz/Documents/the-ai-engineer-challenge/aimakerspace \ No newline at end of file diff --git a/aimakerspace/openai_utils/__init__.py b/aimakerspace/openai_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aimakerspace/openai_utils/chatmodel.py b/aimakerspace/openai_utils/chatmodel.py new file mode 100644 index 000000000..f55fbfe72 --- /dev/null +++ b/aimakerspace/openai_utils/chatmodel.py @@ -0,0 +1,66 @@ +import os +from typing import Any, AsyncIterator, Iterable, List, MutableMapping + +from dotenv import load_dotenv +from openai import AsyncOpenAI, OpenAI + +load_dotenv() + +ChatMessage = MutableMapping[str, Any] + + +class ChatOpenAI: + """Thin wrapper around the OpenAI chat completion APIs.""" + + def __init__(self, model_name: str = "gpt-4o-mini"): + self.model_name = model_name + self.openai_api_key = os.getenv("OPENAI_API_KEY") + if self.openai_api_key is None: + raise ValueError("OPENAI_API_KEY is not set") + + self._client = OpenAI() + self._async_client = AsyncOpenAI() + + def run( + self, + messages: Iterable[ChatMessage], + text_only: bool = True, + **kwargs: Any, + ) -> Any: + """Execute a chat completion request. + + ``messages`` must be an iterable of ``{"role": ..., "content": ...}`` + dictionaries. When ``text_only`` is ``True`` (the default) only the + completion text is returned; otherwise the full response object is + provided. + """ + + message_list = self._coerce_messages(messages) + response = self._client.chat.completions.create( + model=self.model_name, messages=message_list, **kwargs + ) + + if text_only: + return response.choices[0].message.content + + return response + + async def astream( + self, messages: Iterable[ChatMessage], **kwargs: Any + ) -> AsyncIterator[str]: + """Yield streaming completion chunks as they arrive from the API.""" + + message_list = self._coerce_messages(messages) + stream = await self._async_client.chat.completions.create( + model=self.model_name, messages=message_list, stream=True, **kwargs + ) + + async for chunk in stream: + content = chunk.choices[0].delta.content + if content is not None: + yield content + + def _coerce_messages(self, messages: Iterable[ChatMessage]) -> List[ChatMessage]: + if isinstance(messages, list): + return messages + return list(messages) diff --git a/aimakerspace/openai_utils/embedding.py b/aimakerspace/openai_utils/embedding.py new file mode 100644 index 000000000..e06016a89 --- /dev/null +++ b/aimakerspace/openai_utils/embedding.py @@ -0,0 +1,69 @@ +import asyncio +import os +from typing import Iterable, List + +from dotenv import load_dotenv +from openai import AsyncOpenAI, OpenAI + + +class EmbeddingModel: + """Helper for generating embeddings via the OpenAI API.""" + + def __init__(self, embeddings_model_name: str = "text-embedding-3-small"): + load_dotenv() + self.openai_api_key = os.getenv("OPENAI_API_KEY") + if self.openai_api_key is None: + raise ValueError( + "OPENAI_API_KEY environment variable is not set. " + "Please configure it with your OpenAI API key." + ) + + self.embeddings_model_name = embeddings_model_name + self.async_client = AsyncOpenAI() + self.client = OpenAI() + + async def async_get_embeddings(self, list_of_text: Iterable[str]) -> List[List[float]]: + """Return embeddings for ``list_of_text`` using the async client.""" + + embedding_response = await self.async_client.embeddings.create( + input=list(list_of_text), model=self.embeddings_model_name + ) + + return [item.embedding for item in embedding_response.data] + + async def async_get_embedding(self, text: str) -> List[float]: + """Return an embedding for a single text using the async client.""" + + embedding = await self.async_client.embeddings.create( + input=text, model=self.embeddings_model_name + ) + + return embedding.data[0].embedding + + def get_embeddings(self, list_of_text: Iterable[str]) -> List[List[float]]: + """Return embeddings for ``list_of_text`` using the sync client.""" + + embedding_response = self.client.embeddings.create( + input=list(list_of_text), model=self.embeddings_model_name + ) + + return [item.embedding for item in embedding_response.data] + + def get_embedding(self, text: str) -> List[float]: + """Return an embedding for a single text using the sync client.""" + + embedding = self.client.embeddings.create( + input=text, model=self.embeddings_model_name + ) + + return embedding.data[0].embedding + + +if __name__ == "__main__": + embedding_model = EmbeddingModel() + print(asyncio.run(embedding_model.async_get_embedding("Hello, world!"))) + print( + asyncio.run( + embedding_model.async_get_embeddings(["Hello, world!", "Goodbye, world!"]) + ) + ) diff --git a/aimakerspace/openai_utils/prompts.py b/aimakerspace/openai_utils/prompts.py new file mode 100644 index 000000000..b36f750c4 --- /dev/null +++ b/aimakerspace/openai_utils/prompts.py @@ -0,0 +1,60 @@ +import re +from typing import Any, Dict, List + + +class BasePrompt: + """Simple string template helper used to format prompt text.""" + + def __init__(self, prompt: str): + self.prompt = prompt + self._pattern = re.compile(r"\{([^}]+)\}") + + def format_prompt(self, **kwargs: Any) -> str: + """Return the prompt with ``kwargs`` substituted for placeholders.""" + + matches = self._pattern.findall(self.prompt) + replacements = {match: kwargs.get(match, "") for match in matches} + return self.prompt.format(**replacements) + + def get_input_variables(self) -> List[str]: + """Return the placeholder names used by this prompt.""" + + return self._pattern.findall(self.prompt) + + +class RolePrompt(BasePrompt): + """Prompt template that also captures an accompanying chat role.""" + + def __init__(self, prompt: str, role: str): + super().__init__(prompt) + self.role = role + + def create_message(self, apply_format: bool = True, **kwargs: Any) -> Dict[str, str]: + """Build an OpenAI chat message dictionary for this prompt.""" + + content = self.format_prompt(**kwargs) if apply_format else self.prompt + return {"role": self.role, "content": content} + + +class SystemRolePrompt(RolePrompt): + def __init__(self, prompt: str): + super().__init__(prompt, "system") + + +class UserRolePrompt(RolePrompt): + def __init__(self, prompt: str): + super().__init__(prompt, "user") + + +class AssistantRolePrompt(RolePrompt): + def __init__(self, prompt: str): + super().__init__(prompt, "assistant") + + +if __name__ == "__main__": + prompt = BasePrompt("Hello {name}, you are {age} years old") + print(prompt.format_prompt(name="John", age=30)) + + prompt = SystemRolePrompt("Hello {name}, you are {age} years old") + print(prompt.create_message(name="John", age=30)) + print(prompt.get_input_variables()) diff --git a/aimakerspace/text_utils.py b/aimakerspace/text_utils.py new file mode 100644 index 000000000..fa9b5d1e5 --- /dev/null +++ b/aimakerspace/text_utils.py @@ -0,0 +1,147 @@ +from pathlib import Path +from typing import Iterable, List + +import PyPDF2 + + +class TextFileLoader: + """Load plain-text documents from a single file or an entire directory.""" + + def __init__(self, path: str, encoding: str = "utf-8"): + self.path = Path(path) + self.encoding = encoding + self.documents: List[str] = [] + + def load(self) -> None: + """Populate ``self.documents`` from the configured path.""" + + self.documents = list(self._iter_documents()) + + def load_file(self) -> None: + """Load a single file specified by ``self.path``.""" + + self.documents = [self._read_text_file(self.path)] + + def load_directory(self) -> None: + """Load all text files contained within ``self.path``.""" + + self.documents = list(self._iter_directory(self.path)) + + def load_documents(self) -> List[str]: + """Convenience wrapper returning the loaded documents.""" + + self.load() + return self.documents + + def _iter_documents(self) -> Iterable[str]: + if self.path.is_dir(): + yield from self._iter_directory(self.path) + elif self.path.is_file() and self.path.suffix.lower() == ".txt": + yield self._read_text_file(self.path) + else: + raise ValueError( + "Provided path must be a directory or a .txt file: " f"{self.path}" + ) + + def _iter_directory(self, directory: Path) -> Iterable[str]: + for entry in sorted(directory.rglob("*.txt")): + if entry.is_file(): + yield self._read_text_file(entry) + + def _read_text_file(self, file_path: Path) -> str: + with file_path.open("r", encoding=self.encoding) as file_handle: + return file_handle.read() + + +class CharacterTextSplitter: + """Naively split long strings into overlapping character chunks.""" + + def __init__( + self, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ): + if chunk_size <= chunk_overlap: + raise ValueError("Chunk size must be greater than chunk overlap") + + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + + def split(self, text: str) -> List[str]: + """Split ``text`` into chunks preserving the configured overlap.""" + + step = self.chunk_size - self.chunk_overlap + return [text[i : i + self.chunk_size] for i in range(0, len(text), step)] + + def split_texts(self, texts: List[str]) -> List[str]: + """Split multiple texts and flatten the resulting chunks.""" + + chunks: List[str] = [] + for text in texts: + chunks.extend(self.split(text)) + return chunks + + +class PDFLoader: + """Extract text from PDF files stored at a path.""" + + def __init__(self, path: str): + self.path = Path(path) + self.documents: List[str] = [] + + def load(self) -> None: + """Populate ``self.documents`` from the configured path.""" + + self.documents = list(self._iter_documents()) + + def load_file(self) -> None: + """Load a single PDF specified by ``self.path``.""" + + self.documents = [self._read_pdf(self.path)] + + def load_directory(self) -> None: + """Load all PDF files contained within ``self.path``.""" + + self.documents = list(self._iter_directory(self.path)) + + def load_documents(self) -> List[str]: + """Convenience wrapper returning the loaded documents.""" + + self.load() + return self.documents + + def _iter_documents(self) -> Iterable[str]: + if self.path.is_dir(): + yield from self._iter_directory(self.path) + elif self.path.is_file() and self.path.suffix.lower() == ".pdf": + yield self._read_pdf(self.path) + else: + raise ValueError( + "Provided path must be a directory or a .pdf file: " f"{self.path}" + ) + + def _iter_directory(self, directory: Path) -> Iterable[str]: + for entry in sorted(directory.rglob("*.pdf")): + if entry.is_file(): + yield self._read_pdf(entry) + + def _read_pdf(self, file_path: Path) -> str: + with file_path.open("rb") as file_handle: + pdf_reader = PyPDF2.PdfReader(file_handle) + extracted_pages = [page.extract_text() or "" for page in pdf_reader.pages] + return "\n".join(extracted_pages) + + +if __name__ == "__main__": + loader = TextFileLoader("data/KingLear.txt") + loader.load() + splitter = CharacterTextSplitter() + chunks = splitter.split_texts(loader.documents) + print(len(chunks)) + print(chunks[0]) + print("--------") + print(chunks[1]) + print("--------") + print(chunks[-2]) + print("--------") + print(chunks[-1]) diff --git a/aimakerspace/vectordatabase.py b/aimakerspace/vectordatabase.py new file mode 100644 index 000000000..1eb32c1e1 --- /dev/null +++ b/aimakerspace/vectordatabase.py @@ -0,0 +1,105 @@ +import asyncio +from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union + +import numpy as np + +from aimakerspace.openai_utils.embedding import EmbeddingModel + + +def cosine_similarity(vector_a: np.ndarray, vector_b: np.ndarray) -> float: + """Return the cosine similarity between two vectors.""" + + norm_a = np.linalg.norm(vector_a) + norm_b = np.linalg.norm(vector_b) + if norm_a == 0 or norm_b == 0: + return 0.0 + + dot_product = np.dot(vector_a, vector_b) + return float(dot_product / (norm_a * norm_b)) + + +class VectorDatabase: + """Minimal in-memory vector store backed by numpy arrays.""" + + def __init__(self, embedding_model: Optional[EmbeddingModel] = None): + self.vectors: Dict[str, np.ndarray] = {} + self.embedding_model = embedding_model or EmbeddingModel() + + def insert(self, key: str, vector: Iterable[float]) -> None: + """Store ``vector`` so that it can be retrieved with ``key`` later on.""" + + self.vectors[key] = np.asarray(vector, dtype=float) + + def search( + self, + query_vector: Iterable[float], + k: int, + distance_measure: Callable[[np.ndarray, np.ndarray], float] = cosine_similarity, + ) -> List[Tuple[str, float]]: + """Return the ``k`` vectors most similar to ``query_vector``.""" + + if k <= 0: + raise ValueError("k must be a positive integer") + + query = np.asarray(query_vector, dtype=float) + scores = [ + (key, distance_measure(query, vector)) + for key, vector in self.vectors.items() + ] + scores.sort(key=lambda item: item[1], reverse=True) + return scores[:k] + + def search_by_text( + self, + query_text: str, + k: int, + distance_measure: Callable[[np.ndarray, np.ndarray], float] = cosine_similarity, + return_as_text: bool = False, + ) -> Union[List[Tuple[str, float]], List[str]]: + """Vector search using an embedding generated from ``query_text``.""" + + query_vector = self.embedding_model.get_embedding(query_text) + results = self.search(query_vector, k, distance_measure) + if return_as_text: + return [result[0] for result in results] + return results + + def retrieve_from_key(self, key: str) -> Optional[np.ndarray]: + """Return the stored vector for ``key`` if present.""" + + return self.vectors.get(key) + + async def abuild_from_list(self, list_of_text: List[str]) -> "VectorDatabase": + """Populate the vector store asynchronously from raw text snippets.""" + + embeddings = await self.embedding_model.async_get_embeddings(list_of_text) + for text, embedding in zip(list_of_text, embeddings): + self.insert(text, embedding) + return self + + +if __name__ == "__main__": + list_of_text = [ + "I like to eat broccoli and bananas.", + "I ate a banana and spinach smoothie for breakfast.", + "Chinchillas and kittens are cute.", + "My sister adopted a kitten yesterday.", + "Look at this cute hamster munching on a piece of broccoli.", + ] + + vector_db = VectorDatabase() + vector_db = asyncio.run(vector_db.abuild_from_list(list_of_text)) + k = 2 + + searched_vector = vector_db.search_by_text("I think fruit is awesome!", k=k) + print(f"Closest {k} vector(s):", searched_vector) + + retrieved_vector = vector_db.retrieve_from_key( + "I like to eat broccoli and bananas." + ) + print("Retrieved vector:", retrieved_vector) + + relevant_texts = vector_db.search_by_text( + "I think fruit is awesome!", k=k, return_as_text=True + ) + print(f"Closest {k} text(s):", relevant_texts) diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 000000000..e985853ed --- /dev/null +++ b/api/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/api/app.py b/api/app.py index 4fe8d0ba8..d84dc3a3c 100644 --- a/api/app.py +++ b/api/app.py @@ -1,5 +1,5 @@ # Import required FastAPI components for building the API -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware # Import Pydantic for data validation and settings management @@ -7,7 +7,12 @@ # Import OpenAI client for interacting with OpenAI's API from openai import OpenAI import os -from typing import Optional +from typing import Optional, List +import PyPDF2 +import io +from aimakerspace.vectordatabase import VectorDatabase +from aimakerspace.openai_utils.chatmodel import ChatOpenAI +import asyncio # Initialize FastAPI application with a title app = FastAPI(title="OpenAI Chat API") @@ -17,23 +22,83 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], # Allows requests from any origin - allow_credentials=True, # Allows cookies to be included in requests + allow_credentials=False, # Allows cookies to be included in requests allow_methods=["*"], # Allows all HTTP methods (GET, POST, etc.) allow_headers=["*"], # Allows all headers in requests ) +# Global vector database instance and embedding model +vector_db = None + # Define the data model for chat requests using Pydantic # This ensures incoming request data is properly validated class ChatRequest(BaseModel): - developer_message: str # Message from the developer/system user_message: str # Message from the user model: Optional[str] = "gpt-4.1-mini" # Optional model selection with default api_key: str # OpenAI API key for authentication +def extract_text_from_pdf(pdf_file: bytes) -> List[str]: + """Extract text from PDF and split it into chunks.""" + pdf_reader = PyPDF2.PdfReader(io.BytesIO(pdf_file)) + chunks = [] + + for page in pdf_reader.pages: + text = page.extract_text() + # Split text into smaller chunks (simple approach - split by paragraphs) + paragraphs = text.split('\n\n') + chunks.extend([p.strip() for p in paragraphs if p.strip()]) + + return chunks + +@app.post("/api/upload-pdf") +async def upload_pdf(file: UploadFile = File(...), api_key: str = None): + if not api_key: + raise HTTPException(status_code=400, detail="API key is required") + + try: + # Read the PDF file + contents = await file.read() + + # Extract text from PDF + text_chunks = extract_text_from_pdf(contents) + + if not text_chunks: + raise HTTPException(status_code=400, detail="No text could be extracted from the PDF") + + # Initialize vector database with the chunks + global vector_db + os.environ["OPENAI_API_KEY"] = api_key + vector_db = VectorDatabase() + await vector_db.abuild_from_list(text_chunks) + + return {"message": "PDF processed successfully", "chunk_count": len(text_chunks)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + # Define the main chat endpoint that handles POST requests @app.post("/api/chat") async def chat(request: ChatRequest): try: + if not vector_db.vectors: + raise HTTPException(status_code=400, detail="No PDF has been uploaded yet. Please upload a PDF first.") + + # Get relevant chunks from the vector database + relevant_chunks = vector_db.search_by_text( + request.user_message, + k=3, + return_as_text=True + ) + + print(relevant_chunks) + + # Create the system message with context + system_message = f"""You are a helpful AI assistant that answers questions based ONLY on the provided context. +If the question cannot be answered using the context, say that you cannot answer the question with the available information. +Do not make up or infer information that is not in the context. + +Context from the PDF: +{' '.join(relevant_chunks)}""" + # Initialize OpenAI client with the provided API key client = OpenAI(api_key=request.api_key) @@ -43,7 +108,7 @@ async def generate(): stream = client.chat.completions.create( model=request.model, messages=[ - {"role": "developer", "content": request.developer_message}, + {"role": "system", "content": system_message}, {"role": "user", "content": request.user_message} ], stream=True # Enable streaming response diff --git a/api/requirements.txt b/api/requirements.txt index f2d9a1cbc..5e4a4cbfa 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,4 +2,7 @@ fastapi==0.115.12 uvicorn==0.34.2 openai==1.77.0 pydantic==2.11.4 -python-multipart==0.0.18 \ No newline at end of file +python-multipart==0.0.18 +PyPDF2==3.0.1 +numpy==1.26.4 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/api/vercel.json b/api/vercel.json index b5f952634..502c86cb7 100644 --- a/api/vercel.json +++ b/api/vercel.json @@ -1,9 +1,18 @@ { - "version": 2, - "builds": [ - { "src": "app.py", "use": "@vercel/python" } - ], - "routes": [ - { "src": "/(.*)", "dest": "app.py" } - ] - } \ No newline at end of file + "version": 2, + "builds": [ + { + "src": "app.py", + "use": "@vercel/python" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "app.py" + } + ], + "env": { + "PYTHON_VERSION": "3.9" + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..1403e903b --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + diff --git a/frontend/README.md b/frontend/README.md index 56347bab6..5db4871f5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,211 @@ -### Front End +# OpenAI Chat Frontend -Please populate this README with instructions on how to run the application! \ No newline at end of file +A beautiful, modern chat interface built with Next.js and TypeScript for interacting with the OpenAI Chat API backend. + +## Features + +- 🎨 **Modern UI**: Beautiful, responsive design with dark/light theme support +- πŸ” **Secure**: API key input with password-style field and client-side validation +- ⚑ **Real-time Streaming**: Live streaming responses from OpenAI's GPT models +- πŸ“± **Responsive**: Works perfectly on desktop, tablet, and mobile devices +- βš™οΈ **Configurable**: Adjustable system messages and model selection +- 🎯 **User-friendly**: Intuitive chat interface with typing indicators and message history + +## Prerequisites + +- Node.js 18.0 or higher +- npm or yarn package manager +- The FastAPI backend server running (see `../api/README.md`) +- An OpenAI API key + +## Installation + +1. **Navigate to the frontend directory:** + + ```bash + cd frontend + ``` + +2. **Install dependencies:** + + ```bash + npm install + ``` + +3. **Set up environment variables:** + + ```bash + cp .env.example .env.local + ``` + + Edit `.env.local` and update the API URL if needed: + + ``` + NEXT_PUBLIC_API_URL=http://localhost:8000 + ``` + +## Running the Application + +### Development Mode + +Start the development server: + +```bash +npm run dev +``` + +The application will be available at `http://localhost:3000` + +### Production Build + +1. **Build the application:** + + ```bash + npm run build + ``` + +2. **Start the production server:** + ```bash + npm start + ``` + +## Usage + +1. **Start the Backend**: Make sure the FastAPI backend is running on `http://localhost:8000` + +2. **Open the Frontend**: Navigate to `http://localhost:3000` in your browser + +3. **Enter API Key**: On first visit, you'll be prompted to enter your OpenAI API key + + - Get your API key from [OpenAI Platform](https://platform.openai.com/api-keys) + - The key is stored securely in your browser session only + +4. **Start Chatting**: + + - Type your message in the input field + - Press Enter to send (Shift+Enter for new lines) + - Watch as the AI responds in real-time with streaming + +5. **Configure Settings** (Optional): + - Click the settings icon to adjust the system message + - Select different GPT models (gpt-4.1-mini, gpt-4, gpt-3.5-turbo) + - Customize how the AI should behave + +## Deployment + +### Vercel Deployment + +This frontend is optimized for deployment on Vercel: + +1. **Connect your repository** to Vercel +2. **Set environment variables** in the Vercel dashboard: + - `NEXT_PUBLIC_API_URL`: Your deployed backend API URL +3. **Deploy**: Vercel will automatically build and deploy your application + +### Manual Deployment + +1. **Build the application:** + + ```bash + npm run build + ``` + +2. **Export static files** (optional): + + ```bash + npm run export + ``` + +3. **Deploy the `out/` or `.next/` directory** to your hosting provider + +## Project Structure + +``` +frontend/ +β”œβ”€β”€ components/ # React components +β”‚ β”œβ”€β”€ ApiKeySetup.tsx # API key input component +β”‚ β”œβ”€β”€ ChatInterface.tsx # Main chat interface +β”‚ └── MessageBubble.tsx # Individual message component +β”œβ”€β”€ pages/ # Next.js pages +β”‚ β”œβ”€β”€ _app.tsx # App wrapper +β”‚ β”œβ”€β”€ _document.tsx # HTML document structure +β”‚ └── index.tsx # Main page +β”œβ”€β”€ styles/ # Global styles +β”‚ └── globals.css # Tailwind CSS and custom styles +β”œβ”€β”€ types/ # TypeScript type definitions +β”‚ └── index.ts # Shared types +└── public/ # Static assets +``` + +## Configuration + +### Environment Variables + +- `NEXT_PUBLIC_API_URL`: The URL of your FastAPI backend (default: http://localhost:8000) + +### Styling + +The application uses Tailwind CSS with a custom design system: + +- Proper contrast ratios for accessibility +- Responsive breakpoints for mobile-first design +- Dark/light theme variables +- Custom animations and transitions + +## Troubleshooting + +### Common Issues + +1. **"Backend server is not accessible"** + + - Ensure the FastAPI backend is running on the correct port + - Check that CORS is properly configured in the backend + - Verify the `NEXT_PUBLIC_API_URL` environment variable + +2. **API Key Issues** + + - Ensure your OpenAI API key starts with "sk-" + - Check that you have sufficient credits in your OpenAI account + - Verify the key has the necessary permissions + +3. **Build Errors** + + - Run `npm install` to ensure all dependencies are installed + - Check for TypeScript errors with `npm run lint` + - Ensure Node.js version is 18.0 or higher + +4. **Streaming Not Working** + - Check browser console for JavaScript errors + - Verify the backend is returning streaming responses + - Test the backend API directly to isolate issues + +### Performance Tips + +- The application automatically handles message history and scrolling +- Streaming responses provide immediate feedback without waiting for complete responses +- API keys are stored securely in browser session storage +- Messages are optimized for mobile viewing with responsive design + +## Security Notes + +- API keys are stored only in browser session storage and never sent to our servers +- All communication with OpenAI happens through your own API key +- HTTPS is recommended for production deployments +- Consider implementing rate limiting for production use + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## Support + +For issues and questions: + +1. Check the troubleshooting section above +2. Verify the backend API is working correctly +3. Check browser console for error messages +4. Ensure environment variables are set correctly diff --git a/frontend/components/ApiKeySetup.tsx b/frontend/components/ApiKeySetup.tsx new file mode 100644 index 000000000..c50da1ebe --- /dev/null +++ b/frontend/components/ApiKeySetup.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { Eye, EyeOff, Key, AlertCircle } from "lucide-react"; + +interface ApiKeySetupProps { + onApiKeySubmit: (apiKey: string) => void; +} + +export default function ApiKeySetup({ onApiKeySubmit }: ApiKeySetupProps) { + const [apiKey, setApiKey] = useState(""); + const [showApiKey, setShowApiKey] = useState(false); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!apiKey.trim()) { + setError("Please enter your OpenAI API key"); + return; + } + + if (!apiKey.startsWith("sk-")) { + setError('Invalid API key format. OpenAI API keys start with "sk-"'); + return; + } + + setIsLoading(true); + + try { + // Test the API key by making a health check to the backend + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000" + }/api/health` + ); + + if (!response.ok) { + throw new Error( + "Backend server is not accessible. Please ensure the API server is running." + ); + } + + onApiKeySubmit(apiKey.trim()); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to validate API key" + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+

Welcome!

+

+ Enter your OpenAI API key to get started with the chat assistant. +

+
+ +
+
+ +
+ setApiKey(e.target.value)} + placeholder="sk-..." + className="w-full px-3 py-2 pr-10 border border-input rounded-md bg-background focus:ring-2 focus:ring-ring focus:border-transparent outline-none transition-colors" + disabled={isLoading} + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + + +
+ +
+

+ Don't have an API key?{" "} + + Get one from OpenAI + +

+
+
+
+
+ ); +} + diff --git a/frontend/components/ChatInterface.tsx b/frontend/components/ChatInterface.tsx new file mode 100644 index 000000000..05513a838 --- /dev/null +++ b/frontend/components/ChatInterface.tsx @@ -0,0 +1,267 @@ +import { useState, useRef, useEffect } from "react"; +import { Send, Settings, MessageSquare, User, Bot } from "lucide-react"; +import MessageBubble from "./MessageBubble"; +import PDFUpload from "./PDFUpload"; +import { Message } from "@/types"; + +interface ChatInterfaceProps { + apiKey: string; + onApiKeyReset: () => void; +} + +export default function ChatInterface({ + apiKey, + onApiKeyReset, +}: ChatInterfaceProps) { + const [messages, setMessages] = useState([]); + const [userMessage, setUserMessage] = useState(""); + const [model, setModel] = useState("gpt-4.1-mini"); + const [isLoading, setIsLoading] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [isPdfUploaded, setIsPdfUploaded] = useState(false); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!userMessage.trim() || isLoading || !isPdfUploaded) return; + + const newUserMessage: Message = { + id: Date.now().toString(), + role: "user", + content: userMessage, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, newUserMessage]); + setUserMessage(""); + setIsLoading(true); + + // Create assistant message with streaming content + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: "assistant", + content: "", + timestamp: new Date(), + isStreaming: true, + }; + + setMessages((prev) => [...prev, assistantMessage]); + + try { + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000" + }/api/chat`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_message: userMessage, + model, + api_key: apiKey, + }), + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error("No response body"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + const chunk = decoder.decode(value); + + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { ...msg, content: msg.content + chunk } + : msg + ) + ); + } + + // Mark streaming as complete + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id ? { ...msg, isStreaming: false } : msg + ) + ); + } catch (error) { + console.error("Chat error:", error); + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { + ...msg, + content: + "Sorry, there was an error processing your request. Please try again.", + isStreaming: false, + isError: true, + } + : msg + ) + ); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e as any); + } + }; + + const handleTextareaResize = () => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = + Math.min(textareaRef.current.scrollHeight, 120) + "px"; + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

AI Assistant

+

{model}

+
+
+ +
+ + +
+
+ + {/* Settings Panel */} + {showSettings && ( +
+
+ + +
+ + setIsPdfUploaded(true)} + /> +
+ )} + + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ +

+ Upload a PDF to start +

+

+ {isPdfUploaded + ? "PDF uploaded! Start asking questions about the document." + : "Click the settings icon to upload a PDF and start chatting."} +

+
+
+ ) : ( + <> + {messages.map((message) => ( + + ))} +
+ + )} +
+ + {/* Input Form */} +
+
+
+