From f25c2a5cd41c2000e064afd8937628ae4f0287b4 Mon Sep 17 00:00:00 2001 From: Yamumsbumb Date: Mon, 8 Jun 2026 19:08:24 +1000 Subject: [PATCH] Add standalone AI assistant chatbot (#1) * Add standalone AI assistant chatbot Co-authored-by: Yamumsbumb * Clean up ai-assistant build config Co-authored-by: Yamumsbumb --------- Co-authored-by: Cursor Agent Co-authored-by: Yamumsbumb --- ai-assistant/.env.example | 5 + ai-assistant/.gitignore | 12 + ai-assistant/README.md | 93 +++ ai-assistant/backend/__init__.py | 1 + ai-assistant/backend/main.py | 124 +++ ai-assistant/frontend/app/globals.css | 405 ++++++++++ ai-assistant/frontend/app/layout.tsx | 22 + ai-assistant/frontend/app/page.tsx | 361 +++++++++ ai-assistant/frontend/next.config.ts | 16 + ai-assistant/frontend/package-lock.json | 979 ++++++++++++++++++++++++ ai-assistant/frontend/package.json | 27 + ai-assistant/frontend/tsconfig.json | 36 + ai-assistant/install.sh | 49 ++ ai-assistant/requirements.txt | 5 + ai-assistant/scripts/test-build.sh | 17 + 15 files changed, 2152 insertions(+) create mode 100644 ai-assistant/.env.example create mode 100644 ai-assistant/.gitignore create mode 100644 ai-assistant/README.md create mode 100644 ai-assistant/backend/__init__.py create mode 100644 ai-assistant/backend/main.py create mode 100644 ai-assistant/frontend/app/globals.css create mode 100644 ai-assistant/frontend/app/layout.tsx create mode 100644 ai-assistant/frontend/app/page.tsx create mode 100644 ai-assistant/frontend/next.config.ts create mode 100644 ai-assistant/frontend/package-lock.json create mode 100644 ai-assistant/frontend/package.json create mode 100644 ai-assistant/frontend/tsconfig.json create mode 100755 ai-assistant/install.sh create mode 100644 ai-assistant/requirements.txt create mode 100755 ai-assistant/scripts/test-build.sh diff --git a/ai-assistant/.env.example b/ai-assistant/.env.example new file mode 100644 index 00000000..93c296af --- /dev/null +++ b/ai-assistant/.env.example @@ -0,0 +1,5 @@ +OPENAI_API_KEY=sk-your-openai-api-key +OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL= +CORS_ORIGINS=http://localhost:3100,http://127.0.0.1:3100 +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/ai-assistant/.gitignore b/ai-assistant/.gitignore new file mode 100644 index 00000000..015795fa --- /dev/null +++ b/ai-assistant/.gitignore @@ -0,0 +1,12 @@ +.env +.env.local +.venv/ + +**/__pycache__/ +*.py[cod] + +frontend/.env*.local +frontend/.next/ +frontend/next-env.d.ts +frontend/node_modules/ +frontend/out/ diff --git a/ai-assistant/README.md b/ai-assistant/README.md new file mode 100644 index 00000000..7b74c803 --- /dev/null +++ b/ai-assistant/README.md @@ -0,0 +1,93 @@ +# AI Assistant + +Standalone full-stack AI chatbot example built with: + +- Next.js App Router frontend on `http://localhost:3100` +- FastAPI backend on `http://localhost:8000` +- OpenAI Chat Completions API configured through environment variables + +This directory is intentionally isolated from the SmartPerfetto application +entry points. It can run next to the main project without using port 3000. + +## Project layout + +```text +ai-assistant/ + .env.example + install.sh + requirements.txt + backend/ + main.py + frontend/ + app/ + globals.css + layout.tsx + page.tsx + package.json + scripts/ + test-build.sh +``` + +## Environment + +Copy the example file and set your API key: + +```bash +cd ai-assistant +cp .env.example .env +``` + +Required: + +- `OPENAI_API_KEY`: API key used by the FastAPI backend. + +Optional: + +- `OPENAI_MODEL`: defaults to `gpt-4o-mini`. +- `OPENAI_BASE_URL`: use when targeting an OpenAI-compatible endpoint. +- `CORS_ORIGINS`: comma-separated allowed browser origins. +- `NEXT_PUBLIC_API_BASE_URL`: frontend API URL. Defaults to + `http://localhost:8000` in the Next.js app. + +## Install + +```bash +cd ai-assistant +./install.sh +``` + +The installer creates `.venv`, installs Python dependencies from +`requirements.txt`, installs the Next.js app dependencies, and creates `.env` +from `.env.example` if needed. + +## Run + +Start the backend: + +```bash +cd ai-assistant +source .venv/bin/activate +uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Start the frontend in another terminal: + +```bash +cd ai-assistant/frontend +npm run dev +``` + +Open `http://localhost:3100`. + +## Verification + +Run the standalone build smoke: + +```bash +cd ai-assistant +./scripts/test-build.sh +``` + +The check compiles the FastAPI backend with Python `compileall`, installs the +frontend dependencies using `npm ci` when a lockfile exists, and runs +`next build`. diff --git a/ai-assistant/backend/__init__.py b/ai-assistant/backend/__init__.py new file mode 100644 index 00000000..1652e34b --- /dev/null +++ b/ai-assistant/backend/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/ai-assistant/backend/main.py b/ai-assistant/backend/main.py new file mode 100644 index 00000000..acebf4b1 --- /dev/null +++ b/ai-assistant/backend/main.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from pathlib import Path +from typing import Literal +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from openai import AsyncOpenAI, OpenAIError +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +ENV_FILE = Path(__file__).resolve().parents[1] / ".env" + + +class Settings(BaseSettings): + openai_api_key: str | None = None + openai_model: str = "gpt-4o-mini" + openai_base_url: str | None = None + cors_origins: str = "http://localhost:3100,http://127.0.0.1:3100" + + model_config = SettingsConfigDict(env_file=str(ENV_FILE), extra="ignore") + + @property + def allowed_origins(self) -> list[str]: + return [ + origin.strip() + for origin in self.cors_origins.split(",") + if origin.strip() + ] + + +settings = Settings() + +app = FastAPI( + title="AI Assistant API", + description="FastAPI backend for the standalone Next.js AI chatbot.", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class ChatMessage(BaseModel): + role: Literal["system", "user", "assistant"] + content: str = Field(..., min_length=1) + + +class ChatRequest(BaseModel): + conversation_id: str | None = None + messages: list[ChatMessage] = Field(..., min_length=1) + + +class AssistantMessage(BaseModel): + id: str + role: Literal["assistant"] + content: str + + +class ChatResponse(BaseModel): + conversation_id: str + message: AssistantMessage + model: str + + +@app.get("/health") +async def health() -> dict[str, str | bool]: + return { + "status": "ok", + "openai_configured": bool(settings.openai_api_key), + "model": settings.openai_model, + } + + +@app.post("/api/chat", response_model=ChatResponse) +async def chat(request: ChatRequest) -> ChatResponse: + if not settings.openai_api_key: + raise HTTPException( + status_code=500, + detail="OPENAI_API_KEY is not configured. Copy .env.example to .env and set a key.", + ) + + client = AsyncOpenAI( + api_key=settings.openai_api_key, + base_url=settings.openai_base_url or None, + ) + + try: + completion = await client.chat.completions.create( + model=settings.openai_model, + messages=[ + {"role": message.role, "content": message.content} + for message in request.messages + ], + ) + except OpenAIError as exc: + raise HTTPException( + status_code=502, + detail=f"OpenAI request failed: {exc}", + ) from exc + + content = completion.choices[0].message.content + if not content: + raise HTTPException( + status_code=502, + detail="OpenAI returned an empty assistant response.", + ) + + return ChatResponse( + conversation_id=request.conversation_id or f"conv_{uuid4().hex}", + message=AssistantMessage( + id=f"msg_{uuid4().hex}", + role="assistant", + content=content, + ), + model=settings.openai_model, + ) diff --git a/ai-assistant/frontend/app/globals.css b/ai-assistant/frontend/app/globals.css new file mode 100644 index 00000000..c6be2051 --- /dev/null +++ b/ai-assistant/frontend/app/globals.css @@ -0,0 +1,405 @@ +:root { + color-scheme: light; + --background: #f6f8fb; + --panel: #ffffff; + --panel-muted: #eef3fb; + --border: #dce4ef; + --text: #101828; + --muted: #667085; + --primary: #2563eb; + --primary-dark: #1d4ed8; + --primary-soft: #dbeafe; + --danger: #b42318; + --danger-soft: #fef3f2; + --shadow: 0 24px 60px rgba(15, 23, 42, 0.12); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.14), transparent 32rem), + var(--background); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + margin: 0; +} + +button, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + background: rgba(255, 255, 255, 0.86); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; +} + +.sidebar-header, +.chat-header { + align-items: center; + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.eyebrow { + color: var(--primary); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.08em; + margin: 0 0 0.2rem; + text-transform: uppercase; +} + +h1, +h2, +h3 { + margin: 0; +} + +h1 { + font-size: 1.6rem; +} + +h2 { + font-size: clamp(1.2rem, 3vw, 1.7rem); +} + +.new-chat-button, +.composer button { + background: var(--primary); + border: 0; + border-radius: 999px; + color: #ffffff; + font-weight: 800; + padding: 0.85rem 1rem; + transition: + background 160ms ease, + transform 160ms ease; +} + +.new-chat-button:hover, +.composer button:hover:not(:disabled) { + background: var(--primary-dark); + transform: translateY(-1px); +} + +.conversation-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.55rem; + overflow-y: auto; +} + +.conversation-item { + background: transparent; + border: 1px solid transparent; + border-radius: 1rem; + color: var(--text); + display: grid; + gap: 0.25rem; + padding: 0.85rem; + text-align: left; +} + +.conversation-item:hover, +.conversation-item.active { + background: var(--panel-muted); + border-color: var(--border); +} + +.conversation-item span { + font-weight: 750; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-item small { + color: var(--muted); +} + +.chat-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + min-width: 0; + padding: 1.25rem; +} + +.chat-header { + background: rgba(255, 255, 255, 0.86); + border: 1px solid var(--border); + border-radius: 1.35rem; + box-shadow: var(--shadow); + padding: 1rem 1.2rem; +} + +.status-pill { + background: var(--primary-soft); + border-radius: 999px; + color: var(--primary-dark); + font-size: 0.8rem; + font-weight: 800; + padding: 0.42rem 0.7rem; +} + +.messages { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + padding: 1.25rem 0; +} + +.empty-state { + align-items: center; + align-self: center; + background: rgba(255, 255, 255, 0.82); + border: 1px solid var(--border); + border-radius: 1.5rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + margin: auto; + max-width: 32rem; + padding: 2rem; + text-align: center; +} + +.empty-icon { + align-items: center; + background: linear-gradient(135deg, var(--primary), #7c3aed); + border-radius: 1.25rem; + color: #ffffff; + display: flex; + font-weight: 900; + height: 3.5rem; + justify-content: center; + margin-bottom: 1rem; + width: 3.5rem; +} + +.empty-state p { + color: var(--muted); + line-height: 1.6; + margin: 0.65rem 0 0; +} + +.message { + display: flex; + gap: 0.8rem; + max-width: min(48rem, 92%); +} + +.message.user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message-avatar { + align-items: center; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + display: flex; + flex: 0 0 auto; + font-size: 0.74rem; + font-weight: 900; + height: 2.5rem; + justify-content: center; + width: 2.5rem; +} + +.message-bubble { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 1.25rem; + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); + line-height: 1.6; + padding: 0.9rem 1rem; + white-space: pre-wrap; +} + +.message.user .message-bubble { + background: var(--primary); + border-color: var(--primary); + color: #ffffff; +} + +.message-bubble p { + margin: 0; +} + +.typing { + align-items: center; + display: flex; + gap: 0.3rem; + min-height: 3rem; +} + +.typing span { + animation: pulse 1s ease-in-out infinite; + background: var(--muted); + border-radius: 999px; + display: block; + height: 0.45rem; + width: 0.45rem; +} + +.typing span:nth-child(2) { + animation-delay: 120ms; +} + +.typing span:nth-child(3) { + animation-delay: 240ms; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 50% { + opacity: 1; + transform: translateY(-2px); + } +} + +.error-banner { + background: var(--danger-soft); + border: 1px solid #fecdca; + border-radius: 1rem; + color: var(--danger); + font-weight: 700; + margin-bottom: 0.85rem; + padding: 0.8rem 1rem; +} + +.composer { + align-items: end; + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--border); + border-radius: 1.35rem; + box-shadow: var(--shadow); + display: grid; + gap: 0.8rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: 0.8rem; +} + +.composer textarea { + background: transparent; + border: 0; + color: var(--text); + min-height: 2.8rem; + outline: none; + padding: 0.7rem; + resize: none; +} + +.composer button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.icon-button { + background: var(--panel-muted); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text); + font-weight: 800; + padding: 0.55rem 0.8rem; +} + +.mobile-only { + display: none; +} + +.scrim { + display: none; +} + +@media (max-width: 820px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + bottom: 0; + box-shadow: var(--shadow); + left: 0; + max-width: 22rem; + position: fixed; + top: 0; + transform: translateX(-105%); + transition: transform 180ms ease; + width: 82vw; + z-index: 20; + } + + .sidebar-open { + transform: translateX(0); + } + + .scrim { + background: rgba(15, 23, 42, 0.38); + border: 0; + display: block; + inset: 0; + position: fixed; + z-index: 10; + } + + .mobile-only { + display: inline-flex; + } + + .chat-panel { + min-height: 100vh; + padding: 0.8rem; + } + + .chat-header { + border-radius: 1rem; + } + + .status-pill { + display: none; + } + + .message { + max-width: 100%; + } + + .composer { + border-radius: 1rem; + grid-template-columns: 1fr; + } +} diff --git a/ai-assistant/frontend/app/layout.tsx b/ai-assistant/frontend/app/layout.tsx new file mode 100644 index 00000000..dd087aff --- /dev/null +++ b/ai-assistant/frontend/app/layout.tsx @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AI Assistant", + description: "A full-stack OpenAI chatbot built with Next.js and FastAPI.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/ai-assistant/frontend/app/page.tsx b/ai-assistant/frontend/app/page.tsx new file mode 100644 index 00000000..748622f4 --- /dev/null +++ b/ai-assistant/frontend/app/page.tsx @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +"use client"; + +import type { FormEvent, KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; +const STORAGE_KEY = "ai-assistant.conversations"; + +type ChatRole = "user" | "assistant"; + +type Message = { + id: string; + role: ChatRole; + content: string; + createdAt: string; +}; + +type Conversation = { + id: string; + title: string; + messages: Message[]; + updatedAt: string; +}; + +type ApiChatResponse = { + conversation_id: string; + message: { + id: string; + role: "assistant"; + content: string; + }; + model: string; +}; + +function nowIso() { + return new Date().toISOString(); +} + +function createId(prefix: string) { + const randomId = + globalThis.crypto?.randomUUID?.() ?? + Math.random().toString(36).slice(2); + + return `${prefix}_${randomId.replaceAll("-", "")}`; +} + +function createConversation(): Conversation { + const createdAt = nowIso(); + + return { + id: createId("conv"), + title: "New conversation", + messages: [], + updatedAt: createdAt, + }; +} + +function titleFromMessage(content: string) { + const trimmed = content.trim().replace(/\s+/g, " "); + + if (!trimmed) { + return "New conversation"; + } + + return trimmed.length > 42 ? `${trimmed.slice(0, 42)}...` : trimmed; +} + +function formatConversationTime(value: string) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +async function parseError(response: Response) { + try { + const body = (await response.json()) as { detail?: unknown }; + if (typeof body.detail === "string") { + return body.detail; + } + } catch { + // Use the status text when the API does not return JSON. + } + + return response.statusText || "Request failed"; +} + +export default function Home() { + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(""); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [hydrated, setHydrated] = useState(false); + const [isSending, setIsSending] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const messagesEndRef = useRef(null); + + const activeConversation = useMemo( + () => + conversations.find( + (conversation) => conversation.id === activeConversationId, + ) ?? conversations[0], + [activeConversationId, conversations], + ); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + try { + const parsed = JSON.parse(stored) as Conversation[]; + + if (Array.isArray(parsed) && parsed.length > 0) { + setConversations(parsed); + setActiveConversationId(parsed[0].id); + setHydrated(true); + return; + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + + const firstConversation = createConversation(); + setConversations([firstConversation]); + setActiveConversationId(firstConversation.id); + setHydrated(true); + }, []); + + useEffect(() => { + if (hydrated) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)); + } + }, [conversations, hydrated]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [activeConversation?.messages.length, isSending]); + + function startConversation() { + const conversation = createConversation(); + + setConversations((current) => [conversation, ...current]); + setActiveConversationId(conversation.id); + setError(null); + setSidebarOpen(false); + } + + function selectConversation(conversationId: string) { + setActiveConversationId(conversationId); + setError(null); + setSidebarOpen(false); + } + + function updateConversation( + conversationId: string, + updater: (conversation: Conversation) => Conversation, + ) { + setConversations((current) => + current.map((conversation) => + conversation.id === conversationId ? updater(conversation) : conversation, + ), + ); + } + + async function sendMessage(event?: FormEvent) { + event?.preventDefault(); + + const content = input.trim(); + if (!content || !activeConversation || isSending) { + return; + } + + const sentAt = nowIso(); + const userMessage: Message = { + id: createId("msg"), + role: "user", + content, + createdAt: sentAt, + }; + const messagesForApi = [...activeConversation.messages, userMessage]; + + setInput(""); + setError(null); + setIsSending(true); + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + title: + conversation.messages.length === 0 + ? titleFromMessage(content) + : conversation.title, + messages: [...conversation.messages, userMessage], + updatedAt: sentAt, + })); + + try { + const response = await fetch(`${API_BASE_URL}/api/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + conversation_id: activeConversation.id, + messages: messagesForApi.map((message) => ({ + role: message.role, + content: message.content, + })), + }), + }); + + if (!response.ok) { + throw new Error(await parseError(response)); + } + + const data = (await response.json()) as ApiChatResponse; + const assistantMessage: Message = { + id: data.message.id, + role: "assistant", + content: data.message.content, + createdAt: nowIso(), + }; + + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + messages: [...conversation.messages, assistantMessage], + updatedAt: assistantMessage.createdAt, + })); + } catch (unknownError) { + const message = + unknownError instanceof Error + ? unknownError.message + : "Unable to reach the assistant backend."; + setError(message); + } finally { + setIsSending(false); + } + } + + function handleComposerKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void sendMessage(); + } + } + + const hasMessages = (activeConversation?.messages.length ?? 0) > 0; + + return ( +
+ + + {sidebarOpen ? ( + +
+

OpenAI powered

+

{activeConversation?.title ?? "New conversation"}

+
+
FastAPI
+ + +
+ {!hasMessages ? ( +
+
AI
+

Ask anything

+

+ Start a conversation with the assistant. Your chat history is stored + locally in this browser. +

+
+ ) : null} + + {activeConversation?.messages.map((message) => ( +
+
+ {message.role === "user" ? "You" : "AI"} +
+
+

{message.content}

+
+
+ ))} + + {isSending ? ( +
+
AI
+
+ + + +
+
+ ) : null} + +
+
+ + {error ?
{error}
: null} + +
+