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 ? (
+
+ );
+}
diff --git a/ai-assistant/frontend/next.config.ts b/ai-assistant/frontend/next.config.ts
new file mode 100644
index 00000000..b913657e
--- /dev/null
+++ b/ai-assistant/frontend/next.config.ts
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type { NextConfig } from "next";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const configDir = dirname(fileURLToPath(import.meta.url));
+
+const nextConfig: NextConfig = {
+ reactStrictMode: true,
+ turbopack: {
+ root: configDir,
+ },
+};
+
+export default nextConfig;
diff --git a/ai-assistant/frontend/package-lock.json b/ai-assistant/frontend/package-lock.json
new file mode 100644
index 00000000..58fe8c5e
--- /dev/null
+++ b/ai-assistant/frontend/package-lock.json
@@ -0,0 +1,979 @@
+{
+ "name": "ai-assistant-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ai-assistant-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "next": "^16.2.7",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
+ "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz",
+ "integrity": "sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.7.tgz",
+ "integrity": "sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.7.tgz",
+ "integrity": "sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.7.tgz",
+ "integrity": "sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.7.tgz",
+ "integrity": "sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.7.tgz",
+ "integrity": "sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.7.tgz",
+ "integrity": "sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.7.tgz",
+ "integrity": "sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.7.tgz",
+ "integrity": "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
+ "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.34",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
+ "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001797",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
+ "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.7.tgz",
+ "integrity": "sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.2.7",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.2.7",
+ "@next/swc-darwin-x64": "16.2.7",
+ "@next/swc-linux-arm64-gnu": "16.2.7",
+ "@next/swc-linux-arm64-musl": "16.2.7",
+ "@next/swc-linux-x64-gnu": "16.2.7",
+ "@next/swc-linux-x64-musl": "16.2.7",
+ "@next/swc-win32-arm64-msvc": "16.2.7",
+ "@next/swc-win32-x64-msvc": "16.2.7",
+ "sharp": "^0.34.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
+ "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/ai-assistant/frontend/package.json b/ai-assistant/frontend/package.json
new file mode 100644
index 00000000..72a9ced9
--- /dev/null
+++ b/ai-assistant/frontend/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "ai-assistant-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3100",
+ "build": "next build",
+ "start": "next start -p 3100"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "dependencies": {
+ "next": "^16.2.7",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^6.0.3"
+ },
+ "overrides": {
+ "postcss": "^8.5.10"
+ }
+}
diff --git a/ai-assistant/frontend/tsconfig.json b/ai-assistant/frontend/tsconfig.json
new file mode 100644
index 00000000..79b19819
--- /dev/null
+++ b/ai-assistant/frontend/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/ai-assistant/install.sh b/ai-assistant/install.sh
new file mode 100755
index 00000000..7db42888
--- /dev/null
+++ b/ai-assistant/install.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -euo pipefail
+
+APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$APP_DIR"
+
+command -v python3 >/dev/null 2>&1 || {
+ echo "python3 is required but was not found." >&2
+ exit 1
+}
+
+command -v node >/dev/null 2>&1 || {
+ echo "Node.js is required but was not found." >&2
+ exit 1
+}
+
+command -v npm >/dev/null 2>&1 || {
+ echo "npm is required but was not found." >&2
+ exit 1
+}
+
+python3 -m venv .venv
+"$APP_DIR/.venv/bin/python" -m pip install --upgrade pip
+"$APP_DIR/.venv/bin/python" -m pip install -r requirements.txt
+
+npm --prefix frontend install
+
+if [ ! -f .env ]; then
+ cp .env.example .env
+ echo "Created .env from .env.example. Add your OPENAI_API_KEY before chatting."
+fi
+
+cat <<'EOF'
+
+AI Assistant is installed.
+
+Start the backend:
+ 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:
+ cd ai-assistant/frontend
+ npm run dev
+
+Open http://localhost:3100.
+EOF
diff --git a/ai-assistant/requirements.txt b/ai-assistant/requirements.txt
new file mode 100644
index 00000000..93f6588e
--- /dev/null
+++ b/ai-assistant/requirements.txt
@@ -0,0 +1,5 @@
+fastapi
+openai
+pydantic-settings
+python-dotenv
+uvicorn[standard]
diff --git a/ai-assistant/scripts/test-build.sh b/ai-assistant/scripts/test-build.sh
new file mode 100755
index 00000000..ffd7fb3b
--- /dev/null
+++ b/ai-assistant/scripts/test-build.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -euo pipefail
+
+APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$APP_DIR"
+
+python3 -m compileall -q backend
+
+if [ -f frontend/package-lock.json ]; then
+ npm --prefix frontend ci
+else
+ npm --prefix frontend install
+fi
+
+npm --prefix frontend run build