From 95f434c390881eb5c770668a2ccc6ec86709d711 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:11:19 +0000 Subject: [PATCH 01/13] Initial plan From b6c77f84b454a169ad0890f40f6bedf074aa746b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:20:22 +0000 Subject: [PATCH 02/13] feat: add Ollama-powered chat interface with full auth integration - Add ChatMessage model to Prisma schema with user scoping - Create database migration for chat_messages table - Add /chat to protected paths in middleware.ts - Add AI Chat link to sidebar navigation - Create /api/chat/ollama API route with streaming, auth, and input sanitization - Create /api/chat/history API route for loading/clearing chat history - Create /chat page with server-side auth and sidebar layout - Create ChatInterface and ChatMessage client components - Add chat types definitions - Add Ollama env vars to .env.example - Add CHAT_SETUP.md documentation - All type-check, lint, and build pass successfully Co-authored-by: syed-reza98 <71028588+syed-reza98@users.noreply.github.com> --- .env.example | 8 + docs/CHAT_SETUP.md | 146 ++++++++++ middleware.ts | 1 + .../migration.sql | 20 ++ prisma/schema.prisma | 24 ++ src/app/api/chat/history/route.ts | 47 ++++ src/app/api/chat/ollama/route.ts | 191 +++++++++++++ src/app/chat/loading.tsx | 25 ++ src/app/chat/page.tsx | 44 +++ src/components/app-sidebar.tsx | 7 + src/components/chat/chat-interface.tsx | 256 ++++++++++++++++++ src/components/chat/chat-message.tsx | 82 ++++++ src/lib/chat-types.ts | 34 +++ 13 files changed, 885 insertions(+) create mode 100644 docs/CHAT_SETUP.md create mode 100644 prisma/migrations/20260315000000_add_chat_messages/migration.sql create mode 100644 src/app/api/chat/history/route.ts create mode 100644 src/app/api/chat/ollama/route.ts create mode 100644 src/app/chat/loading.tsx create mode 100644 src/app/chat/page.tsx create mode 100644 src/components/chat/chat-interface.tsx create mode 100644 src/components/chat/chat-message.tsx create mode 100644 src/lib/chat-types.ts diff --git a/.env.example b/.env.example index db044ada..758b2362 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,11 @@ FACEBOOK_WEBHOOK_VERIFY_TOKEN="" FACEBOOK_ACCESS_LEVEL="STANDARD" FACEBOOK_CONVERSIONS_ACCESS_TOKEN="" FACEBOOK_TEST_EVENT_CODE="TEST89865" + +# Ollama AI Chat Configuration +# Base URL of your Ollama instance (cloud or local) +# For local: http://localhost:11434 +# For cloud: https://your-ollama-cloud-endpoint.example.com +OLLAMA_API_URL="http://localhost:11434" +# Model to use for chat completions (e.g., llama3.2, mistral, codellama) +OLLAMA_MODEL="llama3.2" diff --git a/docs/CHAT_SETUP.md b/docs/CHAT_SETUP.md new file mode 100644 index 00000000..056b6fc3 --- /dev/null +++ b/docs/CHAT_SETUP.md @@ -0,0 +1,146 @@ +# AI Chat Feature – Setup & Usage + +## Overview + +StormCom includes a built-in AI chat interface powered by [Ollama](https://ollama.com/). Authenticated users can access the chat at `/chat` to have real-time conversations with an LLM. Messages are streamed from the Ollama backend and persisted in the database per user. + +## Architecture + +``` +Browser ──► /chat (Next.js page, protected) + │ + ▼ + POST /api/chat/ollama + │ ① Authenticate session (NextAuth) + │ ② Validate & sanitize input + │ ③ Persist user message to DB + │ ④ Send conversation context to Ollama + │ ⑤ Stream response tokens back + │ ⑥ Persist assistant response to DB + ▼ + Ollama API (local or cloud) +``` + +## Prerequisites + +- **Ollama** installed locally or accessible via a cloud endpoint. + - Local install: + - After install, pull a model: `ollama pull llama3.2` +- **PostgreSQL** database with migrations applied. + +## Environment Variables + +Add these to your `.env.local`: + +```bash +# Base URL of your Ollama instance +# Local: http://localhost:11434 +# Cloud: https://your-ollama-cloud.example.com +OLLAMA_API_URL="http://localhost:11434" + +# Model name (must be pulled/available on your Ollama instance) +OLLAMA_MODEL="llama3.2" +``` + +## Database Setup + +The chat feature uses a `ChatMessage` model in the Prisma schema. After pulling the latest code: + +```bash +# Generate Prisma client +npm run prisma:generate + +# Apply migrations +npm run prisma:migrate:dev +``` + +## Usage + +1. Start your Ollama instance (local: `ollama serve`). +2. Start the dev server: `npm run dev`. +3. Log in to StormCom. +4. Navigate to **AI Chat** in the sidebar or go to `/chat`. +5. Type a message and press **Enter** (or click the send button). +6. Responses stream in real-time. + +### Keyboard Shortcuts + +| Shortcut | Action | +| ----------------- | ------------------- | +| `Enter` | Send message | +| `Shift + Enter` | New line in message | + +### Clear History + +Click the **Clear** button in the chat header to delete all your chat history. + +## API Endpoints + +### `POST /api/chat/ollama` + +Send a message and receive a streamed AI response. + +**Authentication**: Required (session cookie) + +**Request body**: +```json +{ + "message": "Hello, how are you?" +} +``` + +**Response**: Streamed `text/plain` – tokens arrive as they are generated. + +### `GET /api/chat/history` + +Retrieve chat history for the authenticated user (up to 100 messages). + +**Response**: +```json +{ + "messages": [ + { + "id": "clx...", + "role": "USER", + "content": "Hello", + "model": "llama3.2", + "createdAt": "2025-01-01T00:00:00.000Z" + } + ] +} +``` + +### `DELETE /api/chat/history` + +Clear all chat history for the authenticated user. + +## Security + +- The `/chat` route is protected by NextAuth middleware – unauthenticated users are redirected to login. +- Every API request validates the user session server-side. +- Chat history is scoped to `session.user.id` – users cannot access other users' messages. +- User input is sanitized (HTML tags stripped, control characters removed). +- Message length is capped at 4,000 characters. +- Ollama credentials / API URL are server-side only (never exposed to the client). + +## Extending + +### Multi-Tenancy + +The `ChatMessage` model can be extended with an `organizationId` field to scope conversations per organization: + +```prisma +model ChatMessage { + // ... existing fields + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id]) +} +``` + +### Different Models + +Set `OLLAMA_MODEL` to any model available on your Ollama instance (e.g., `mistral`, `codellama`, `phi3`). + +### Cloud Deployment + +Point `OLLAMA_API_URL` to a cloud-hosted Ollama instance. Ensure the endpoint is secured and only accessible from your application server. diff --git a/middleware.ts b/middleware.ts index 0e8fb3ff..30e5ffa8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -307,6 +307,7 @@ export default async function middleware(request: NextRequest) { "/team", "/projects", "/products", + "/chat", ]; const isProtectedPath = protectedPaths.some((path) => diff --git a/prisma/migrations/20260315000000_add_chat_messages/migration.sql b/prisma/migrations/20260315000000_add_chat_messages/migration.sql new file mode 100644 index 00000000..4f7507f9 --- /dev/null +++ b/prisma/migrations/20260315000000_add_chat_messages/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "ChatMessageRole" AS ENUM ('USER', 'ASSISTANT'); + +-- CreateTable +CREATE TABLE "chat_messages" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "ChatMessageRole" NOT NULL, + "content" TEXT NOT NULL, + "model" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "chat_messages_userId_createdAt_idx" ON "chat_messages"("userId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 12f7c1d3..48cd9a90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model User { activitiesReceived PlatformActivity[] @relation("PlatformActivityTarget") reviewedStoreRequests StoreRequest[] @relation("StoreRequestReviewer") storeRequests StoreRequest[] @relation("UserStoreRequests") + chatMessages ChatMessage[] } model Account { @@ -1846,6 +1847,29 @@ model LandingPage { @@map("landing_pages") } +// ============================================================================ +// AI CHAT +// ============================================================================ + +enum ChatMessageRole { + USER + ASSISTANT +} + +model ChatMessage { + id String @id @default(cuid()) + userId String + role ChatMessageRole + content String + model String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@map("chat_messages") +} + model LandingPageVersion { id String @id @default(cuid()) pageId String diff --git a/src/app/api/chat/history/route.ts b/src/app/api/chat/history/route.ts new file mode 100644 index 00000000..bbe77660 --- /dev/null +++ b/src/app/api/chat/history/route.ts @@ -0,0 +1,47 @@ +/** + * GET /api/chat/history – Fetch authenticated user's chat history + * DELETE /api/chat/history – Clear authenticated user's chat history + */ + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** Maximum messages returned per request */ +const MAX_HISTORY = 100; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const messages = await prisma.chatMessage.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "asc" }, + take: MAX_HISTORY, + select: { + id: true, + role: true, + content: true, + model: true, + createdAt: true, + }, + }); + + return NextResponse.json({ messages }); +} + +export async function DELETE() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await prisma.chatMessage.deleteMany({ + where: { userId: session.user.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/chat/ollama/route.ts b/src/app/api/chat/ollama/route.ts new file mode 100644 index 00000000..888ee90b --- /dev/null +++ b/src/app/api/chat/ollama/route.ts @@ -0,0 +1,191 @@ +/** + * POST /api/chat/ollama + * + * Authenticated API route that proxies chat messages to a configured Ollama + * cloud endpoint. Streams the LLM response back to the client and persists + * both user and assistant messages in the database. + * + * Environment variables: + * OLLAMA_API_URL – Base URL of the Ollama instance (default: http://localhost:11434) + * OLLAMA_MODEL – Model name to use (default: llama3.2) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import type { ChatRequest, OllamaRequestBody, OllamaStreamChunk } from "@/lib/chat-types"; + +const OLLAMA_API_URL = process.env.OLLAMA_API_URL || "http://localhost:11434"; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "llama3.2"; + +/** Maximum allowed message length (characters) */ +const MAX_MESSAGE_LENGTH = 4000; + +/** Number of recent messages to include as conversation context */ +const CONTEXT_MESSAGE_COUNT = 20; + +/** + * Strip HTML tags and dangerous patterns from user input. + * Prevents prompt injection and XSS in stored content. + */ +function sanitiseInput(text: string): string { + return text + .replace(/<[^>]*>/g, "") // strip HTML tags + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") // strip control chars + .trim(); +} + +export async function POST(request: NextRequest) { + // ── Auth ────────────────────────────────────────────────────────────── + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const userId = session.user.id; + + // ── Parse & validate ────────────────────────────────────────────────── + let body: ChatRequest; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.message || typeof body.message !== "string") { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + + const userMessage = sanitiseInput(body.message); + if (userMessage.length === 0) { + return NextResponse.json({ error: "Message cannot be empty" }, { status: 400 }); + } + if (userMessage.length > MAX_MESSAGE_LENGTH) { + return NextResponse.json( + { error: `Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters` }, + { status: 400 }, + ); + } + + // ── Persist user message ────────────────────────────────────────────── + await prisma.chatMessage.create({ + data: { userId, role: "USER", content: userMessage, model: OLLAMA_MODEL }, + }); + + // ── Build conversation context ──────────────────────────────────────── + const recentMessages = await prisma.chatMessage.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: CONTEXT_MESSAGE_COUNT, + select: { role: true, content: true }, + }); + + const ollamaMessages = [ + { + role: "system" as const, + content: + "You are a helpful AI assistant. Answer questions clearly and concisely. Use Markdown for formatting when appropriate.", + }, + ...recentMessages.reverse().map((m) => ({ + role: m.role === "USER" ? ("user" as const) : ("assistant" as const), + content: m.content, + })), + ]; + + // ── Call Ollama (streaming) ─────────────────────────────────────────── + const ollamaPayload: OllamaRequestBody = { + model: OLLAMA_MODEL, + messages: ollamaMessages, + stream: true, + }; + + let ollamaRes: Response; + try { + ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(ollamaPayload), + }); + } catch (err) { + console.error("[chat/ollama] Ollama connection error:", err); + return NextResponse.json( + { error: "Failed to connect to AI service. Please ensure Ollama is running." }, + { status: 503 }, + ); + } + + if (!ollamaRes.ok) { + const errorText = await ollamaRes.text().catch(() => "Unknown error"); + console.error("[chat/ollama] Ollama API error:", ollamaRes.status, errorText); + return NextResponse.json( + { error: "AI service returned an error. Please try again later." }, + { status: 502 }, + ); + } + + // ── Stream response back to client ──────────────────────────────────── + let fullAssistantResponse = ""; + + const stream = new ReadableStream({ + async start(controller) { + const reader = ollamaRes.body?.getReader(); + if (!reader) { + controller.close(); + return; + } + + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value, { stream: true }); + // Ollama streams newline-delimited JSON chunks + const lines = text.split("\n").filter(Boolean); + + for (const line of lines) { + try { + const chunk: OllamaStreamChunk = JSON.parse(line); + const token = chunk.message?.content ?? ""; + if (token) { + fullAssistantResponse += token; + controller.enqueue(new TextEncoder().encode(token)); + } + } catch { + // Non-JSON line – skip + } + } + } + } catch (err) { + console.error("[chat/ollama] Stream read error:", err); + } finally { + // Persist assistant response + if (fullAssistantResponse.trim().length > 0) { + try { + await prisma.chatMessage.create({ + data: { + userId, + role: "ASSISTANT", + content: fullAssistantResponse, + model: OLLAMA_MODEL, + }, + }); + } catch (dbErr) { + console.error("[chat/ollama] Failed to persist assistant message:", dbErr); + } + } + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + }, + }); +} diff --git a/src/app/chat/loading.tsx b/src/app/chat/loading.tsx new file mode 100644 index 00000000..e1cec8c5 --- /dev/null +++ b/src/app/chat/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; + +export default function ChatLoading() { + return ( +
+ + + + + + {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ + + +
+
+ ); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 00000000..f78c8b43 --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,44 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { ChatInterface } from "@/components/chat/chat-interface"; +import { SiteHeader } from "@/components/site-header"; +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "AI Chat", + description: "Chat with an AI assistant powered by Ollama", +}; + +export default async function ChatPage() { + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect("/login"); + } + + return ( + + + + +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index c088f3fd..58455d30 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -16,6 +16,7 @@ import { IconHelp, IconInnerShadowTop, IconListDetails, + IconMessageChatbot, IconPackage, IconReport, IconSearch, @@ -173,6 +174,12 @@ const getNavConfig = (session: { user?: { name?: string | null; email?: string | icon: IconUsers, permission: "organization:read", }, + { + title: "AI Chat", + url: "/chat", + icon: IconMessageChatbot, + permission: undefined, // All authenticated users + }, ], navClouds: [ { diff --git a/src/components/chat/chat-interface.tsx b/src/components/chat/chat-interface.tsx new file mode 100644 index 00000000..1811c980 --- /dev/null +++ b/src/components/chat/chat-interface.tsx @@ -0,0 +1,256 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { ChatMessage } from "@/components/chat/chat-message"; +import { IconSend, IconTrash, IconLoader2, IconMessageChatbot } from "@tabler/icons-react"; +import type { ChatMessageData } from "@/lib/chat-types"; + +/** + * Main chat interface component. + * + * Loads existing message history on mount, lets the user send new messages, + * streams the assistant response token-by-token, and provides a "clear" action. + */ +export function ChatInterface() { + const [messages, setMessages] = React.useState([]); + const [input, setInput] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [isHistoryLoading, setIsHistoryLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const scrollRef = React.useRef(null); + const textareaRef = React.useRef(null); + + // ── Load chat history on mount ────────────────────────────────────── + React.useEffect(() => { + async function loadHistory() { + try { + const res = await fetch("/api/chat/history"); + if (res.ok) { + const data = await res.json(); + setMessages(data.messages ?? []); + } + } catch { + // Silently ignore – we'll show empty chat + } finally { + setIsHistoryLoading(false); + } + } + loadHistory(); + }, []); + + // ── Auto-scroll to bottom when messages change ────────────────────── + React.useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + // ── Send message ──────────────────────────────────────────────────── + const handleSend = React.useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + + setError(null); + setInput(""); + setIsLoading(true); + + // Optimistic UI – add user message immediately + const userMsg: ChatMessageData = { + id: `temp-${Date.now()}`, + role: "USER", + content: trimmed, + model: null, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMsg]); + + // Placeholder for assistant response + const assistantMsg: ChatMessageData = { + id: `temp-assistant-${Date.now()}`, + role: "ASSISTANT", + content: "", + model: null, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, assistantMsg]); + + try { + const res = await fetch("/api/chat/ollama", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: trimmed }), + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({ error: "Unknown error" })); + throw new Error(errData.error || `HTTP ${res.status}`); + } + + // Stream the response + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response stream"); + + const decoder = new TextDecoder(); + let fullContent = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + fullContent += chunk; + + setMessages((prev) => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === "ASSISTANT") { + updated[lastIdx] = { ...updated[lastIdx], content: fullContent }; + } + return updated; + }); + } + + // If the response was empty, show a fallback + if (!fullContent.trim()) { + setMessages((prev) => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === "ASSISTANT") { + updated[lastIdx] = { + ...updated[lastIdx], + content: "I didn't receive a response. Please try again.", + }; + } + return updated; + }); + } + } catch (err) { + const errMessage = err instanceof Error ? err.message : "Something went wrong"; + setError(errMessage); + + // Remove the empty assistant placeholder on error + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "ASSISTANT" && !last.content) { + return prev.slice(0, -1); + } + return prev; + }); + } finally { + setIsLoading(false); + textareaRef.current?.focus(); + } + }, [input, isLoading]); + + // ── Clear history ─────────────────────────────────────────────────── + const handleClear = React.useCallback(async () => { + try { + await fetch("/api/chat/history", { method: "DELETE" }); + setMessages([]); + setError(null); + } catch { + setError("Failed to clear history"); + } + }, []); + + // ── Keyboard shortcut (Enter to send, Shift+Enter for newline) ───── + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + return ( + + + + + AI Chat + + + + + + +
+ {isHistoryLoading ? ( +
+ + Loading conversation… +
+ ) : messages.length === 0 ? ( +
+ +

Start a conversation with the AI assistant

+

+ Type a message below and press Enter to send +

+
+ ) : ( + messages.map((msg) => ) + )} + + {isLoading && messages[messages.length - 1]?.role === "ASSISTANT" && messages[messages.length - 1]?.content === "" && ( +
+ + Thinking… +
+ )} +
+
+
+ + + {error && ( +
+ {error} +
+ )} +
+