diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..6c3eae5 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,38 @@ +name: Scrape & Ingest Pipeline +on: + schedule: + - cron: "0 0 1 * *" # monthly on the 1st at midnight + workflow_dispatch: # manual trigger + +jobs: + run-pipeline: + runs-on: ubuntu-latest + defaults: + run: + working-directory: scripts + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('scripts/requirements.txt') }} + + - name: Cache sentence-transformers model + uses: actions/cache@v4 + with: + path: ~/.cache/huggingface + key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run pipeline + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: python main.py diff --git a/.gitignore b/.gitignore index efbe2c9..2fa4de7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ yarn-error.log* .idea # clerk configuration (can include secrets) -/.clerk/ \ No newline at end of file +/.clerk/ +*.pyc +.vscode/settings.json diff --git a/package.json b/package.json index 782d4f1..484bf6c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "dependencies": { "@clerk/nextjs": "^7.3.5", "@flags-sdk/vercel": "1.3.0", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/react": "^3.0.179", "@prisma/client": "^6.4.1", "@supabase/supabase-js": "^2.45.4", "@t3-oss/env-nextjs": "^0.10.1", @@ -31,6 +33,7 @@ "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", "@vercel/analytics": "^2.0.1", + "ai": "^6.0.177", "axios": "^1.7.9", "browser-image-compression": "^2.0.2", "classnames": "^2.5.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab79653..843b086 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,11 +1,13 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") + extensions = [vector] } model User { @@ -127,3 +129,13 @@ enum AllTeamRoles { // multi team MultiTeam @map("Multi Team") } + +model Document { + id BigInt @id @default(autoincrement()) + content String? + metadata Json? + embedding Unsupported("vector(384)")? + + @@map("documents") +} + diff --git a/public/assets/HeliosSideview.png b/public/assets/HeliosSideview.png new file mode 100644 index 0000000..9c1c23e Binary files /dev/null and b/public/assets/HeliosSideview.png differ diff --git a/public/assets/Logo.png b/public/assets/Logo.png new file mode 100644 index 0000000..1a6f443 Binary files /dev/null and b/public/assets/Logo.png differ diff --git a/scripts/clean.py b/scripts/clean.py new file mode 100644 index 0000000..077c9bd --- /dev/null +++ b/scripts/clean.py @@ -0,0 +1,144 @@ +import re + +INPUT_PATH = "/tmp/documents.json" +OUTPUT_PATH = "/tmp/documents_clean.json" + +# --- Patterns to strip --- + +# Image markdown: ![alt](url) +IMAGE_PATTERN = re.compile(r'!\[.*?\]\(.*?\)') + +# Inline links: [text](url) -> keep just the text +INLINE_LINK_PATTERN = re.compile(r'\[([^\]]+)\]\([^\)]+\)') + +# Reference-style links and bare URLs in angle brackets +REF_LINK_PATTERN = re.compile(r'\[.*?\]\[.*?\]') + +# The repeated footer block that appears in every Calgary Solar Car page +FOOTER_MARKERS = [ + "Follow us on our Social Media", + "Contact Information", + "communications@calgarysolarcar.ca", + "sponsorship@calgarysolarcar.ca", + "ENC 36, Schulich School of Engineering", + "© 2026 Calgary Solar Car", +] + +# Wikipedia boilerplate sections to drop entirely (these appear as headings) +WIKIPEDIA_DROP_SECTIONS = [ + "## References", + "## External links", + "## See also", +] + +# Wikipedia navigation tables that are pure link noise +# These large navbox tables start with "| [Photovoltaics]" or "| [Energy]" etc. +NAVBOX_PATTERN = re.compile( + r'\| \[(?:Photovoltaics|Energy|Electric vehicles|Alternative fuel vehicles|' + r'The Sun|Natural resources|World Solar Challenge|American Solar Challenge|' + r'Formula Sun Grand Prix|University of Calgary)\].*', + re.DOTALL +) + +# Wikipedia citation noise: [\1], [^1], \[1\], etc. +CITATION_PATTERN = re.compile(r'\[\\?\^?\\?\d+\\?\]') + +# Escaped brackets from markdown conversion +ESCAPED_BRACKET_PATTERN = re.compile(r'\\[\[\]]') + + +def strip_footer(text: str) -> str: + """Remove the repeated Calgary Solar Car footer from a document.""" + for marker in FOOTER_MARKERS: + idx = text.find(marker) + if idx != -1: + # Walk back to find the start of the footer block + text = text[:idx].rstrip() + break + return text + + +def strip_wikipedia_boilerplate(text: str) -> str: + """Remove References, External links, See also sections and navboxes.""" + for section_header in WIKIPEDIA_DROP_SECTIONS: + idx = text.find(section_header) + if idx != -1: + text = text[:idx].rstrip() + break + + # Remove navbox tables (large repeated link blocks at the end) + text = NAVBOX_PATTERN.sub("", text) + return text + + +def clean_text(text: str, source: str) -> str: + # 1. Strip image markdown + text = IMAGE_PATTERN.sub("", text) + + # 2. Strip citations like [1], [^2], \[3\] + text = CITATION_PATTERN.sub("", text) + text = ESCAPED_BRACKET_PATTERN.sub("", text) + + # 3. Source-specific cleanup + if "calgarysolarcar.ca" in source: + text = strip_footer(text) + + if "wikipedia.org" in source: + text = strip_wikipedia_boilerplate(text) + # Convert inline links to plain text for Wikipedia + text = INLINE_LINK_PATTERN.sub(r'\1', text) + text = REF_LINK_PATTERN.sub("", text) + + # 4. Collapse excessive blank lines (more than 2 in a row -> 2) + text = re.sub(r'\n{3,}', '\n\n', text) + + # 5. Strip leading/trailing whitespace + text = text.strip() + + return text + + +def is_empty_doc(text: str) -> bool: + """Return True if the document has no meaningful content after cleaning.""" + # Remove all markdown, whitespace, and punctuation + stripped = re.sub(r'[#\s\-\*_>|]', '', text) + return len(stripped) < 100 + + +def clean(docs): + print(f"Cleaning {len(docs)} documents.") + + cleaned_docs = [] + skipped = [] + + for doc in docs: + source = doc.get("source", "") + original_text = doc.get("content", "") + cleaned_text = clean_text(original_text, source) + + if is_empty_doc(cleaned_text): + skipped.append(source) + print(f" SKIPPED (empty after cleaning): {source}") + continue + + original_len = len(original_text) + cleaned_len = len(cleaned_text) + reduction = 100 * (1 - cleaned_len / original_len) if original_len > 0 else 0 + + print(f" OK: {source}") + print(f" {original_len:,} chars -> {cleaned_len:,} chars ({reduction:.1f}% reduction)") + + cleaned_docs.append({ + "id": doc.get("id"), + "content": cleaned_text, + "source": source, + }) + + print(f"\nDone. {len(cleaned_docs)} documents cleaned.") + if skipped: + print(f"Skipped {len(skipped)} empty documents: {skipped}") + + return cleaned_docs + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/scripts/ingest.py b/scripts/ingest.py new file mode 100644 index 0000000..40c0059 --- /dev/null +++ b/scripts/ingest.py @@ -0,0 +1,115 @@ +import json +import os +import sys +from pathlib import Path + +# Attempt to load required libraries, guide user if not installed +try: + from langchain_text_splitters import RecursiveCharacterTextSplitter + from sentence_transformers import SentenceTransformer + import psycopg2 + from dotenv import load_dotenv +except ImportError as e: + print(f"Missing dependency: {e}") + print("Please install requirements: pip install langchain-text-splitters sentence-transformers psycopg2-binary python-dotenv") + sys.exit(1) + +# Load environment variables +dotenv_path = Path(__file__).parent.parent / ".env.local" +load_dotenv(dotenv_path) + +db_url = os.environ.get("DATABASE_URL") +if not db_url: + print("Error: DATABASE_URL is missing. It should be set as a GitHub Actions secret containing the Supabase connection string.") + sys.exit(1) + + +def ingest(data): + # 1. Text Splitter + print("Splitting text...") + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=1024, + chunk_overlap=128, + separators=["\n\n", "\n", " ", ""] + ) + + chunks = [] + for doc in data: + splits = text_splitter.split_text(doc["content"]) + for i, split in enumerate(splits): + chunks.append({ + "content": split, + "metadata": { + "source": doc.get("source", "Unknown"), + "chunk_id": f"{doc.get('id', 'doc')}_{i}" + } + }) + + # Process members.json if it exists + members_path = os.path.join(os.path.dirname(__file__), '..', 'members.json') + if os.path.exists(members_path): + print("Processing members data...") + with open(members_path, "r", encoding="utf-8") as f: + members_data = json.load(f) + + for i, member in enumerate(members_data): + name = f"{member.get('firstName', '')} {member.get('lastName', '')}".strip() + role = member.get('teamRole', 'Unknown Role') + study = member.get('fieldOfStudy', 'Unknown Field') + year = member.get('schoolYear', '') + joined = member.get('yearJoined', '') + email = "redacted" + about = member.get('about', '') + linkedin = "redacted" + + content = f"Team Member: {name}\nRole: {role}\nField of Study: {study} (Year: {year})\nJoined Team in: {joined}\nContact: {email}\nAbout: {about}\nLinkedIn: {linkedin}" + + chunks.append({ + "content": content, + "metadata": { + "source": "members.json", + "chunk_id": f"member_{i}" + } + }) + + print(f"Total chunks created: {len(chunks)}") + + # 2. Generate Embeddings locally (no API key needed) + print("Loading embedding model (all-MiniLM-L6-v2: 384 dimensions)...") + model = SentenceTransformer('all-MiniLM-L6-v2') + + texts = [chunk["content"] for chunk in chunks] + print("Computing embeddings...") + embeddings = model.encode(texts, show_progress_bar=True) + + # 3. Store in local PostgreSQL + print("Storing vectors in local PostgreSQL...") + conn = None + try: + conn = psycopg2.connect(db_url) + with conn.cursor() as cur: + try: + # Clear old data so re-runs don't duplicate + cur.execute("DELETE FROM documents") + + for i, chunk in enumerate(chunks): + embedding_list = embeddings[i].tolist() + cur.execute( + "INSERT INTO documents (content, metadata, embedding) VALUES (%s, %s, %s)", + (chunk["content"], json.dumps(chunk["metadata"]), str(embedding_list)) + ) + conn.commit() + except Exception as db_err: + conn.rollback() + raise db_err + print(f"Success! {len(chunks)} documents stored in local PostgreSQL.") + except Exception as e: + print(f"Database operation failed: {e}") + raise e + finally: + if conn is not None: + conn.close() + + +if __name__ == "__main__": + pass diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..a26dba6 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,66 @@ +import time +import os +import sys + +# Add the project root to sys.path to allow importing from the 'scripts' package +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from scripts.scrape import scrape +from scripts.clean import clean +from scripts.ingest import ingest + +def get_memory_usage(): + """Returns the current memory usage in MB.""" + try: + import resource + # ru_maxrss is in KB on Linux + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + except ImportError: + # Fallback for Windows if psutil is installed, otherwise 0 + try: + import psutil + process = psutil.Process(os.getpid()) + return process.memory_info().rss / (1024 * 1024) + except ImportError: + return 0 + +def main(): + start_total = time.perf_counter() + + # 1. Scrape data from websites + print("\n--- Starting Scraping ---") + t0 = time.perf_counter() + data = scrape() + t_scrape = time.perf_counter() - t0 + + # 2. Clean and format the scraped data + print("\n--- Starting Cleaning ---") + t0 = time.perf_counter() + cleaned_data = clean(data) + t_clean = time.perf_counter() - t0 + + # 3. Ingest the cleaned data into Supabase (embeddings) + print("\n--- Starting Ingestion ---") + t0 = time.perf_counter() + ingest(cleaned_data) + t_ingest = time.perf_counter() - t0 + + end_total = time.perf_counter() + total_duration = end_total - start_total + memory_used = get_memory_usage() + + print("\n" + "="*40) + print(" AWS RESOURCE USAGE REPORT") + print("="*40) + print(f"Scraping Duration: {t_scrape:7.2f}s") + print(f"Cleaning Duration: {t_clean:7.2f}s") + print(f"Ingestion Duration: {t_ingest:7.2f}s") + print("-" * 40) + print(f"Total Execution Time: {total_duration:7.2f}s") + print(f"Peak Memory Usage: {memory_used:7.2f} MB") + print(f"Documents Processed: {len(cleaned_data):7d}") + print("="*40) + print("\nAll tasks completed successfully!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..e59217d --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,5 @@ +requests==2.32.5 +langchain-text-splitters==1.1.1 +sentence-transformers==5.3.0 +psycopg2-binary==2.9.12 +python-dotenv==1.2.2 diff --git a/scripts/scrape.py b/scripts/scrape.py new file mode 100644 index 0000000..551f724 --- /dev/null +++ b/scripts/scrape.py @@ -0,0 +1,70 @@ +import json +import requests +import os + + +url = "https://api.firecrawl.dev/v1/scrape" + +target_urls = [ + "https://calgarysolarcar.ca", + "https://calgarysolarcar.ca/cars", + "https://calgarysolarcar.ca/team", + "https://calgarysolarcar.ca/our-work", + "https://calgarysolarcar.ca/recruitment", + "https://calgarysolarcar.ca/sponsors", + "https://en.wikipedia.org/wiki/University_of_Calgary_Solar_Car_Team", + "https://en.wikipedia.org/wiki/American_Solar_Challenge", + "https://en.wikipedia.org/wiki/World_Solar_Challenge", + "https://en.wikipedia.org/wiki/Formula_Sun_Grand_Prix", + "https://en.wikipedia.org/wiki/Solar_car", + "https://en.wikipedia.org/wiki/Solar_energy", +] + +def scrape(): + documents = [] + for i, target in enumerate(target_urls): + print("Scraping:", target) + + try: + response = requests.post( + url, + headers={ + "Authorization": f"Bearer {os.getenv('FC_API_KEY')}", + "Content-Type": "application/json" + }, + json={ + "url": target, + "formats": ["markdown"] + }, + timeout=30 + ) + response.raise_for_status() + data = response.json() + + if data and "data" in data and "markdown" in data["data"]: + markdown_content = data["data"]["markdown"] + if markdown_content: + documents.append({ + "id": f"doc_{i}", + "content": markdown_content, + "source": target + }) + else: + print(f"Warning: Empty markdown content returned for {target}") + else: + print(f"Warning: Expected data structure missing in response for {target}. Response: {data}") + + except requests.exceptions.Timeout: + print(f"Error: Timeout occurred while scraping {target}") + except requests.exceptions.JSONDecodeError: + print(f"Error: Failed to parse JSON response for {target}") + except requests.exceptions.RequestException as e: + print(f"Error: Network or HTTP exception occurred for {target}: {e}") + except Exception as e: + print(f"Error: An unexpected error occurred while processing {target}: {e}") + + print("Done. Scraping completed.") + return documents + +if __name__ == "__main__": + scrape() \ No newline at end of file diff --git a/src/app/_components/ChatBot/ChatAvatar.tsx b/src/app/_components/ChatBot/ChatAvatar.tsx new file mode 100644 index 0000000..5a901a1 --- /dev/null +++ b/src/app/_components/ChatBot/ChatAvatar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { motion } from "framer-motion"; +import Image from "next/image"; + +import styles from "./ChatBot.module.scss"; + +export function CarAvatar({ + isThinking = false, + size = 48, +}: { + size?: number; + isThinking?: boolean; +}) { + return ( + + Helios car + + ); +} + +export function TypingDots() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} diff --git a/src/app/_components/ChatBot/ChatBot.module.scss b/src/app/_components/ChatBot/ChatBot.module.scss new file mode 100644 index 0000000..1034255 --- /dev/null +++ b/src/app/_components/ChatBot/ChatBot.module.scss @@ -0,0 +1,310 @@ +.triggerButton { + position: fixed; + bottom: 28px; + right: 28px; + width: 80px; + height: 80px; + border-radius: 9999px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + background: linear-gradient(to bottom right, #ff8080, #ff0000, #8b0000); +} + +.triggerRing { + position: absolute; + inset: 10px; + border-radius: 9999px; +} + +.triggerImage { + position: relative; + z-index: 10; + width: 90%; + height: auto; + object-fit: contain; + filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.04)) + drop-shadow(0 4px 3px rgba(0, 0, 0, 0.1)); +} + +.chatPanel { + position: fixed; + bottom: 24px; + right: 24px; + width: calc(100vw - 48px); + max-width: 420px; + height: calc(100vh - 48px); + max-height: 620px; + display: flex; + flex-direction: column; + border-radius: 24px; + overflow: hidden; + z-index: 60; + border: 1px solid rgba(255, 0, 0, 0.2); + background-color: rgba(8, 16, 26, 0.9); + backdrop-filter: blur(24px); + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.header { + padding: 16px 20px; + background: linear-gradient(to bottom, rgba(255, 0, 0, 0.1), transparent); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + gap: 12px; +} + +.headerInfo { + flex: 1; +} + +.headerTitle { + font-weight: 700; + font-size: 16px; + color: #f0ede8; + letter-spacing: 0.025em; +} + +.headerStatus { + font-size: 12px; + margin-top: 2px; + display: flex; + align-items: center; + gap: 6px; +} + +.headerStatusLoading { + color: #ff0000; +} + +.headerStatusReady { + color: #4caf80; +} + +.statusDot { + width: 6px; + height: 6px; + border-radius: 9999px; +} + +.statusDotLoading { + background-color: #ff0000; + box-shadow: 0 0 6px #ff0000; +} + +.statusDotReady { + background-color: #4caf80; + box-shadow: 0 0 6px #4caf80; +} + +.closeButton { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 9999px; + border: 1px solid rgba(255, 255, 255, 0.1); + background-color: rgba(255, 255, 255, 0.05); + color: rgba(240, 237, 232, 0.6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s; +} + +.closeButton:hover, +.closeButton:focus-visible { + background-color: rgba(255, 255, 255, 0.1); + outline: 2px solid #ff0000; +} + +.messagesContainer { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.messageRow { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.messageRowUser { + flex-direction: row-reverse; +} + +.avatarWrapper { + margin-top: 4px; +} + +.messageBubbleWrapper { + max-width: 72%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.messageBubbleWrapperUser { + align-items: flex-end; +} + +.messageBubbleWrapperBot { + align-items: flex-start; +} + +.messageBubble { + padding: 12px 16px; + font-size: 14.5px; + line-height: 1.625; + letter-spacing: -0.015em; + color: #f0ede8; +} + +.messageBubbleUser { + border-radius: 20px 20px 4px 20px; + background: linear-gradient(to bottom right, #ff0000, #8b0000); + border: none; + box-shadow: 0 4px 20px rgba(255, 0, 0, 0.3); +} + +.messageBubbleBot { + border-radius: 4px 20px 20px 20px; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.suggestionsContainer { + padding: 0 16px 12px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.suggestionButton { + font-size: 12px; + padding: 6px 12px; + border-radius: 9999px; + border: 1px solid rgba(255, 90, 90, 0.25); + background-color: rgba(255, 90, 90, 0.05); + color: #ff5a5a; + cursor: pointer; + transition: all 0.2s; +} + +.suggestionButton:hover, +.suggestionButton:focus-visible { + background-color: rgba(255, 90, 90, 0.15); + border-color: rgba(255, 90, 90, 0.5); + outline: 2px solid #ff5a5a; +} + +.inputForm { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + gap: 10px; + align-items: flex-end; +} + +.inputField { + flex: 1; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 10px 14px; + color: #f0ede8; + font-size: 14px; + resize: none; + line-height: 1.625; + transition: border-color 0.2s; + overflow: hidden; +} + +.inputField:focus { + outline: none; + border-color: rgba(255, 0, 0, 0.4); +} + +.sendButton { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 12px; + border: 1px solid rgba(255, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.sendButtonActive { + background: linear-gradient(to bottom right, #ff0000, #8b0000); + cursor: pointer; +} + +.sendButtonActive:hover { + transform: scale(1.05); +} + +.sendButtonActive:focus-visible { + outline: 2px solid #ff0000; +} + +.sendButtonDisabled { + background-color: rgba(255, 255, 255, 0.05); + cursor: not-allowed; + opacity: 0.4; +} + +.typingDotsContainer { + display: flex; + gap: 5px; + align-items: center; + padding: 4px; +} + +.typingDot { + width: 7px; + height: 7px; + border-radius: 9999px; + background-color: #ff0000; + opacity: 0.8; +} + +.avatarImage { + width: 100%; + height: 100%; + object-fit: contain; +} + +.avatarContainer { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.boldText { + color: #ff5a5a; + font-weight: bold; +} + +.chatBotContainer { + @media (max-width: 767px) { + display: none !important; + } +} diff --git a/src/app/_components/ChatBot/ChatBot.tsx b/src/app/_components/ChatBot/ChatBot.tsx new file mode 100644 index 0000000..5756fd4 --- /dev/null +++ b/src/app/_components/ChatBot/ChatBot.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { DefaultChatTransport } from "ai"; +import { AnimatePresence } from "framer-motion"; +import { useState } from "react"; + +import { useChat } from "@ai-sdk/react"; + +import styles from "./ChatBot.module.scss"; +import { ChatPanel } from "./ChatPanel"; +import { ChatTrigger } from "./ChatTrigger"; + +const chatTransport = new DefaultChatTransport({ api: "/api/chat" }); + +export default function SolarChatbot() { + const { messages, sendMessage, status } = useChat({ + transport: chatTransport, + }); + + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ {/* Floating trigger button */} + + {!isOpen && setIsOpen(true)} />} + + + {/* Chat panel */} + + {isOpen && ( + setIsOpen(false)} + sendMessage={sendMessage} + status={status} + /> + )} + +
+ ); +} diff --git a/src/app/_components/ChatBot/ChatPanel.tsx b/src/app/_components/ChatBot/ChatPanel.tsx new file mode 100644 index 0000000..6d0dc18 --- /dev/null +++ b/src/app/_components/ChatBot/ChatPanel.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; + +import { type UIMessage } from "@ai-sdk/react"; + +import { CarAvatar, TypingDots } from "./ChatAvatar"; +import styles from "./ChatBot.module.scss"; + +const SUGGESTED = [ + "Tell me about the team", + "Who are the sponsors?", + "What is a solar car?", + "When are the next races?", + "Tell me about Schulich Helios", +]; + +function parseMarkdownBold(text: string) { + const parts = text.split(/\*\*(.*?)\*\*/g); + return parts.map((part, i) => + i % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + ); +} + +function getMessageText(msg: UIMessage): string { + if (!msg.parts) return ""; + return (msg.parts as { type: string; text?: string }[]) + .filter( + (p): p is { type: "text"; text: string } => + p?.type === "text" && typeof p.text === "string", + ) + .map((p) => p.text) + .join(""); +} + +function Message({ msg }: { msg: UIMessage }) { + const isUser = msg.role === "user"; + const text = getMessageText(msg); + return ( + + {!isUser && ( +
+ +
+ )} +
+
+ {isUser ? text : parseMarkdownBold(text)} +
+
+
+ ); +} + +function SendIcon() { + return ( + + ); +} + +interface ChatPanelProps { + messages: UIMessage[]; + sendMessage: (options: { text: string }) => Promise; + status: string; + onClose: () => void; +} + +export function ChatPanel({ + messages, + onClose, + sendMessage, + status, +}: ChatPanelProps) { + const [input, setInput] = useState(""); + const isLoading = status === "streaming" || status === "submitted"; + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isLoading]); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`; + }, [input]); + + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + }; + + const handleSubmit = ( + e?: React.FormEvent | React.MouseEvent, + ) => { + e?.preventDefault(); + (e as React.FormEvent)?.stopPropagation?.(); + if (!input.trim() || isLoading) return; + void sendMessage({ text: input }); + setInput(""); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (!input.trim() || isLoading) return; + void sendMessage({ text: input }); + setInput(""); + } + }; + + const showTypingDots = status === "submitted"; + + return ( + + {/* Header */} +
+ +
+
SOLARIS
+
+
+ {isLoading ? "Thinking..." : "Solar Car Team Assistant"} +
+
+ +
+ + {/* Messages */} +
+ {messages.length === 0 && ( + +
+ +
+
+
+ Hello! ☀️ I'm SOLARIS, the AI assistant for our Solar Car + Team. Ask me anything about the team, sponsors, or solar + technology! +
+
+
+ )} + + {messages.map((msg) => ( + + ))} + + {showTypingDots && ( + + +
+ +
+
+ )} + +
+
+ + {/* Suggestions */} + {messages.length === 0 && ( + + {SUGGESTED.map((s) => ( + + ))} + + )} + + {/* Input */} +
+