diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..063787d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +.env.local +*.log diff --git a/.env.example b/.env.example index 185f7d2..47d617f 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,10 @@ AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="" # App NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# Ollama (AI semantic search — Phase 8.4) +# Local Ollama instance for embeddings and LLM summarization. +# No data leaves the network. Zero ongoing AI costs. +OLLAMA_HOST="http://localhost:11434" +OLLAMA_EMBED_MODEL="nomic-embed-text" +OLLAMA_LLM_MODEL="qwen3.5:9b" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..970188b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM node:22-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts +RUN npx prisma generate || true + +# Rebuild source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the Next.js app +RUN npm run build + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Copy Next.js standalone output +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copy Prisma schema and migrations for runtime +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/src/generated ./src/generated + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/UPGRADE_PLAN.md b/UPGRADE_PLAN.md index 9e41799..67580af 100644 --- a/UPGRADE_PLAN.md +++ b/UPGRADE_PLAN.md @@ -954,7 +954,7 @@ self-contained, and free to operate. 4. **LLM summarization layer (optional enhancement)** - Pass the top search results + the user's original question to a local Ollama LLM - (`POST http://ollama:11434/api/generate`) using `llama3.1:8b` or `mistral` + (`POST http://ollama:11434/api/generate`) using `qwen3.5:9b` - Generate a natural language summary: *"There are 3 Envy projects currently behind schedule. The most critical is Envy 16 Refresh with 4 overdue deliverables..."* - Return both the AI summary and the structured result list @@ -1001,8 +1001,10 @@ model SearchLog { **New dependencies:** - `pgvector` PostgreSQL extension (installed on the database, not an npm package) - Ollama service (Docker container — `ollama/ollama` image) -- Ollama models: `nomic-embed-text` (embeddings, ~274MB), `llama3.1:8b` (summarization, - ~4.7GB) — pulled automatically on first container start +- Ollama models: `nomic-embed-text` (embeddings, ~274MB), `qwen3.5:9b` (summarization) + — pulled automatically on first container start +- **Note:** `qwen3.5:9b` has been downloaded to the local dev machine and is ready for + testing. This is our chosen summarization model going forward. - No paid API services — everything runs locally **Practical notes:** @@ -1375,7 +1377,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models │ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ │ │ app │ │ db │ │ ollama │ │ │ │ Next.js │ │ PostgreSQL │ │ nomic-embed │ │ -│ │ Port 3000 │ │ + pgvector│ │ llama3.1:8b │ │ +│ │ Port 3000 │ │ + pgvector│ │ qwen3.5:9b │ │ │ │ │──│ Port 5432 │ │ Port 11434 │ │ │ │ │ │ │ │ │ │ │ └────────────┘ └────────────┘ └────────────────┘ │ @@ -1414,7 +1416,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models 3. **`docker/ollama-entrypoint.sh`** (model bootstrap script) - Starts the Ollama server - Checks if required models are already pulled (cached in volume) - - If not, pulls `nomic-embed-text` and `llama3.1:8b` automatically + - If not, pulls `nomic-embed-text` and `qwen3.5:9b` automatically - Subsequent starts skip the pull — models persist in the Docker volume 4. **`docker/db-init.sql`** (database initialization) @@ -1435,7 +1437,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models # Ollama (internal — no need to change) OLLAMA_HOST=http://ollama:11434 OLLAMA_EMBED_MODEL=nomic-embed-text - OLLAMA_LLM_MODEL=llama3.1:8b + OLLAMA_LLM_MODEL=qwen3.5:9b ``` **Key files:** diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2cdbe01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + # ─── PostgreSQL with pgvector ─────────────────────────── + db: + image: pgvector/pgvector:pg17 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hp_prod_tracker + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + # ─── Ollama (local AI embeddings) ────────────────────── + ollama: + image: ollama/ollama:latest + restart: unless-stopped + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + + # ─── Next.js app (production only) ───────────────────── + # Uncomment for deployment. For local dev, run `npm run dev` directly. + # app: + # build: . + # restart: unless-stopped + # ports: + # - "3000:3000" + # environment: + # DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public + # OLLAMA_HOST: http://ollama:11434 + # OLLAMA_EMBED_MODEL: nomic-embed-text + # NODE_ENV: production + # depends_on: + # db: + # condition: service_healthy + +volumes: + pgdata: + ollama_data: diff --git a/package.json b/package.json index bdfa583..77e3d6e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "db:push": "prisma db push", "db:seed": "prisma db seed", "db:studio": "prisma studio", - "db:seed-tracker": "tsx prisma/seed-tracker-data.ts" + "db:seed-tracker": "tsx prisma/seed-tracker-data.ts", + "db:backfill-embeddings": "tsx scripts/backfill-embeddings.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/prisma/migrations/20260306_add_pgvector/migration.sql b/prisma/migrations/20260306_add_pgvector/migration.sql new file mode 100644 index 0000000..569b771 --- /dev/null +++ b/prisma/migrations/20260306_add_pgvector/migration.sql @@ -0,0 +1,28 @@ +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- Add embedding columns to projects and deliverables +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "embedding" vector(768); +ALTER TABLE "deliverables" ADD COLUMN IF NOT EXISTS "embedding" vector(768); + +-- Create indexes for fast cosine similarity search +CREATE INDEX IF NOT EXISTS "projects_embedding_idx" ON "projects" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100); +CREATE INDEX IF NOT EXISTS "deliverables_embedding_idx" ON "deliverables" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100); + +-- Create search_logs table +CREATE TABLE IF NOT EXISTS "search_logs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "query" TEXT NOT NULL, + "resultCount" INTEGER NOT NULL DEFAULT 0, + "clickedId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "search_logs_pkey" PRIMARY KEY ("id") +); + +-- Index for search_logs by user +CREATE INDEX IF NOT EXISTS "search_logs_userId_idx" ON "search_logs" ("userId"); + +-- Foreign key for search_logs +ALTER TABLE "search_logs" ADD CONSTRAINT "search_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77de96b..b2ce19c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -117,6 +117,7 @@ model User { comments Comment[] notifications Notification[] skills UserSkill[] + searchLogs SearchLog[] @@map("users") } @@ -218,6 +219,9 @@ model Project { actualCost Float? agency String? + // pgvector embedding for semantic search (raw SQL — Prisma can't query this directly) + embedding Unsupported("vector(768)")? + organizationId String organization Organization @relation(fields: [organizationId], references: [id]) @@ -247,6 +251,9 @@ model Deliverable { actualDeliveryDate DateTime? wfInputDate DateTime? + // pgvector embedding for semantic search (raw SQL — Prisma can't query this directly) + embedding Unsupported("vector(768)")? + projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@ -407,3 +414,18 @@ model StageSkillRequirement { @@id([stageTemplateId, skillId]) @@map("stage_skill_requirements") } + +// ─── Semantic Search (Phase 8.4) ──────────────────────── + +model SearchLog { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + query String + resultCount Int @default(0) + clickedId String? + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("search_logs") +} diff --git a/scripts/backfill-embeddings.ts b/scripts/backfill-embeddings.ts new file mode 100644 index 0000000..c4648ea --- /dev/null +++ b/scripts/backfill-embeddings.ts @@ -0,0 +1,177 @@ +/** + * Backfill Embeddings Script + * + * One-time script to generate embeddings for all existing projects and deliverables. + * Run with: npx tsx scripts/backfill-embeddings.ts + * + * Prerequisites: + * - PostgreSQL with pgvector extension enabled + * - Ollama running with nomic-embed-text model pulled + * - DATABASE_URL set in environment + */ + +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; +import { + buildProjectText, + buildDeliverableText, + generateEmbedding, + checkOllamaHealth, +} from "../src/lib/services/embedding-service"; + +const connectionString = process.env.DATABASE_URL!; +const adapter = new PrismaPg({ connectionString }); +const prisma = new PrismaClient({ adapter }); + +async function backfillProjects() { + const projects = await prisma.project.findMany({ + include: { + deliverables: { + select: { name: true, status: true, priority: true }, + }, + }, + }); + + console.log(`\n📦 Processing ${projects.length} projects...`); + let success = 0; + let failed = 0; + + for (const project of projects) { + try { + const text = buildProjectText(project); + const embedding = await generateEmbedding(text); + + if (embedding) { + const vectorStr = `[${embedding.join(",")}]`; + await prisma.$executeRawUnsafe( + `UPDATE "projects" SET "embedding" = $1::vector WHERE "id" = $2`, + vectorStr, + project.id + ); + success++; + process.stdout.write( + `\r ✅ Projects: ${success} done, ${failed} failed (${success + failed}/${projects.length})` + ); + } else { + failed++; + console.warn(`\n ⚠️ Failed to generate embedding for project: ${project.name}`); + } + } catch (error) { + failed++; + console.error( + `\n ❌ Error processing project "${project.name}":`, + error instanceof Error ? error.message : error + ); + } + } + + console.log(`\n Projects complete: ${success} succeeded, ${failed} failed`); +} + +async function backfillDeliverables() { + const deliverables = await prisma.deliverable.findMany({ + include: { + project: { select: { name: true, projectCode: true } }, + stages: { + include: { template: { select: { name: true } } }, + }, + }, + }); + + console.log(`\n📋 Processing ${deliverables.length} deliverables...`); + let success = 0; + let failed = 0; + + for (const deliverable of deliverables) { + try { + const text = buildDeliverableText({ + ...deliverable, + stages: deliverable.stages.map((s) => ({ + template: s.template, + status: s.status, + })), + }); + const embedding = await generateEmbedding(text); + + if (embedding) { + const vectorStr = `[${embedding.join(",")}]`; + await prisma.$executeRawUnsafe( + `UPDATE "deliverables" SET "embedding" = $1::vector WHERE "id" = $2`, + vectorStr, + deliverable.id + ); + success++; + process.stdout.write( + `\r ✅ Deliverables: ${success} done, ${failed} failed (${success + failed}/${deliverables.length})` + ); + } else { + failed++; + console.warn( + `\n ⚠️ Failed to generate embedding for deliverable: ${deliverable.name}` + ); + } + } catch (error) { + failed++; + console.error( + `\n ❌ Error processing deliverable "${deliverable.name}":`, + error instanceof Error ? error.message : error + ); + } + } + + console.log( + `\n Deliverables complete: ${success} succeeded, ${failed} failed` + ); +} + +async function main() { + console.log("🔍 Backfill Embeddings — HP CG Production Tracker"); + console.log("═".repeat(50)); + + // Check Ollama health first + console.log("\n🏥 Checking Ollama health..."); + const health = await checkOllamaHealth(); + + if (!health.available) { + console.error( + "❌ Ollama is not available. Make sure it's running at", + process.env.OLLAMA_HOST || "http://localhost:11434" + ); + console.error( + " Start it with: ollama serve (or docker run -p 11434:11434 ollama/ollama)" + ); + process.exit(1); + } + + console.log(`✅ Ollama is running. Available models: ${health.models.join(", ")}`); + + const embedModel = process.env.OLLAMA_EMBED_MODEL || "nomic-embed-text"; + const hasModel = health.models.some((m) => m.includes(embedModel.split(":")[0])); + + if (!hasModel) { + console.error( + `❌ Embedding model "${embedModel}" not found. Pull it with: ollama pull ${embedModel}` + ); + process.exit(1); + } + + console.log(`✅ Using embedding model: ${embedModel}`); + + const startTime = Date.now(); + + await backfillProjects(); + await backfillDeliverables(); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n${"═".repeat(50)}`); + console.log(`✨ Backfill complete in ${elapsed}s`); + + await prisma.$disconnect(); +} + +main().catch((error) => { + console.error("\n💥 Fatal error:", error); + prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/app/api/search/semantic/route.ts b/src/app/api/search/semantic/route.ts new file mode 100644 index 0000000..7ce12af --- /dev/null +++ b/src/app/api/search/semantic/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { + semanticSearch, + logSearch, +} from "@/lib/services/semantic-search-service"; +import { checkOllamaHealth } from "@/lib/services/embedding-service"; + +// POST /api/search/semantic — perform semantic search +export async function POST(request: Request) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const body = await request.json(); + const { query, limit, includeSummary } = body; + + if (!query || typeof query !== "string" || query.trim().length === 0) { + return badRequest("Query string is required"); + } + + if (query.trim().length > 500) { + return badRequest("Query must be 500 characters or less"); + } + + const results = await semanticSearch( + query.trim(), + session!.user.organizationId!, + { + limit: typeof limit === "number" ? Math.min(limit, 50) : 10, + includeSummary: includeSummary !== false, + } + ); + + // Log the search asynchronously (non-blocking) + logSearch(session!.user.id, query.trim(), results.totalResults).catch( + () => {} + ); + + return NextResponse.json(results); + } catch (e) { + return serverError(e); + } +} + +// GET /api/search/semantic/health — check Ollama availability +export async function GET() { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const health = await checkOllamaHealth(); + return NextResponse.json(health); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 35e7bad..5cf58a7 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -13,6 +13,7 @@ import { Monitor, FileSpreadsheet, Search, + Sparkles, } from "lucide-react"; import { useTheme } from "next-themes"; import { @@ -60,6 +61,25 @@ export function CommandPalette() { No results found. + {/* Actions */} + + + runCommand(() => + window.dispatchEvent(new CustomEvent("open-smart-search")) + ) + } + > + + Smart Search + + AI-powered + + + + + + {/* Navigation */} runCommand(() => router.push("/dashboard"))}> diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index d3662cd..89076bb 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { formatDistanceToNow } from "date-fns"; import { @@ -12,6 +13,7 @@ import { AlertTriangle, Unlock, Clock, + Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/layout/theme-toggle"; @@ -23,12 +25,14 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useUnreadCount, useNotifications, useMarkAsRead, useMarkAllAsRead, } from "@/hooks/use-notifications"; +import { SmartSearchPanel } from "@/components/search/smart-search-panel"; import { cn } from "@/lib/utils"; const NOTIF_ICONS: Record = { @@ -43,6 +47,7 @@ const NOTIF_ICONS: Record = { }; export function Topbar() { + const [searchOpen, setSearchOpen] = useState(false); const { data: countData } = useUnreadCount(); const { data: notifications } = useNotifications(10); const markRead = useMarkAsRead(); @@ -51,6 +56,13 @@ export function Topbar() { const unreadCount = countData?.count ?? 0; const items = (notifications as any[]) ?? []; + // Listen for custom event from command palette + useEffect(() => { + const handleOpenSearch = () => setSearchOpen(true); + window.addEventListener("open-smart-search", handleOpenSearch); + return () => window.removeEventListener("open-smart-search", handleOpenSearch); + }, []); + return (
@@ -59,6 +71,22 @@ export function Topbar() {
+ {/* Smart Search button */} + + + + + Smart Search + + @@ -171,6 +199,12 @@ export function Topbar() {
+ + {/* Smart Search Panel */} + setSearchOpen(false)} + />
); } diff --git a/src/components/search/smart-search-panel.tsx b/src/components/search/smart-search-panel.tsx new file mode 100644 index 0000000..4a57ac7 --- /dev/null +++ b/src/components/search/smart-search-panel.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Search, + Sparkles, + X, + FolderKanban, + FileBox, + ArrowRight, + Loader2, + AlertCircle, + Zap, + Filter, + Brain, + Blend, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + useSemanticSearch, + useOllamaHealth, +} from "@/hooks/use-semantic-search"; +import type { SearchResult, SemanticSearchResponse } from "@/hooks/use-semantic-search"; + +// ─── Types ───────────────────────────────────────────── + +interface ConversationEntry { + id: string; + type: "user" | "assistant"; + content: string; + results?: SearchResult[]; + searchType?: SemanticSearchResponse["searchType"]; + timestamp: Date; +} + +// ─── Status badge color helper ───────────────────────── + +function statusColor(status: string): string { + const map: Record = { + ACTIVE: "bg-[var(--status-in-progress)] text-white", + ON_HOLD: "bg-[var(--status-in-review)] text-white", + COMPLETED: "bg-[var(--status-approved)] text-white", + ARCHIVED: "bg-[var(--status-skipped)] text-white", + NOT_STARTED: "bg-[var(--status-not-started)] text-white", + IN_PROGRESS: "bg-[var(--status-in-progress)] text-white", + IN_REVIEW: "bg-[var(--status-in-review)] text-white", + APPROVED: "bg-[var(--status-approved)] text-white", + BLOCKED: "bg-[var(--status-blocked)] text-white", + DELIVERED: "bg-[var(--status-delivered)] text-white", + }; + return map[status] || "bg-[var(--muted)] text-[var(--muted-foreground)]"; +} + +function searchTypeIcon(type: SemanticSearchResponse["searchType"]) { + switch (type) { + case "semantic": + return ; + case "structural": + return ; + case "hybrid": + return ; + } +} + +function searchTypeLabel(type: SemanticSearchResponse["searchType"]) { + switch (type) { + case "semantic": + return "AI semantic search"; + case "structural": + return "Filter-based search"; + case "hybrid": + return "Hybrid search"; + } +} + +// ─── Result Card ─────────────────────────────────────── + +function ResultCard({ + result, + onClick, +}: { + result: SearchResult; + onClick: () => void; +}) { + const isProject = result.type === "project"; + const relevancePct = Math.round(result.relevanceScore * 100); + + return ( + + ); +} + +// ─── Suggested queries ───────────────────────────────── + +const SUGGESTED_QUERIES = [ + "Which projects are running behind?", + "Show me active high priority projects", + "Deliverables in review this week", + "What needs my attention?", +]; + +// ─── Main Panel Component ────────────────────────────── + +export function SmartSearchPanel({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const router = useRouter(); + const inputRef = useRef(null); + const scrollRef = useRef(null); + const [inputValue, setInputValue] = useState(""); + const [conversation, setConversation] = useState([]); + + const search = useSemanticSearch(); + const { data: ollamaHealth } = useOllamaHealth(); + + // Focus input when panel opens + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // Scroll to bottom on new messages + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [conversation, search.isPending]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + const handleSearch = useCallback( + async (query: string) => { + if (!query.trim() || search.isPending) return; + + const userEntry: ConversationEntry = { + id: crypto.randomUUID(), + type: "user", + content: query.trim(), + timestamp: new Date(), + }; + setConversation((prev) => [...prev, userEntry]); + setInputValue(""); + + search.mutate( + { query: query.trim() }, + { + onSuccess: (data) => { + const assistantEntry: ConversationEntry = { + id: crypto.randomUUID(), + type: "assistant", + content: data.summary || `Found ${data.totalResults} result${data.totalResults !== 1 ? "s" : ""}.`, + results: data.results, + searchType: data.searchType, + timestamp: new Date(), + }; + setConversation((prev) => [...prev, assistantEntry]); + }, + onError: (error) => { + const errorEntry: ConversationEntry = { + id: crypto.randomUUID(), + type: "assistant", + content: `Search failed: ${error.message}. Please try again.`, + timestamp: new Date(), + }; + setConversation((prev) => [...prev, errorEntry]); + }, + } + ); + }, + [search] + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSearch(inputValue); + }; + + const handleResultClick = (result: SearchResult) => { + router.push(result.link); + onClose(); + }; + + const handleClear = () => { + setConversation([]); + setInputValue(""); + inputRef.current?.focus(); + }; + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +