From 8cc49b0b08f96639bc8c5db2e9fc19efa3b37aba Mon Sep 17 00:00:00 2001 From: Codespace Runner Date: Sun, 2 Nov 2025 10:51:52 +0000 Subject: [PATCH 1/2] feat: convert to monorepo with Express.js worker service and document processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert repository to Turborepo monorepo structure with pnpm workspace - Create apps/web (Next.js) and apps/worker (Express.js) services - Add packages/db (shared Drizzle ORM schema) and packages/types (shared TypeScript types) - Add packages/tsconfig for shared TypeScript configurations - Implement Express.js worker service with BullMQ queue system and Redis - Build comprehensive document processing pipeline with AI SDK embeddings - Add pgvector support for vector similarity search in PostgreSQL - Create file upload API and frontend components with drag-and-drop - Implement semantic search interface with real-time job status tracking - Add Docker Compose configuration with Redis and pgvector-enabled PostgreSQL - Create comprehensive API routes for document management and search - Update navigation and create dedicated Documents page - Add proper error handling, progress tracking, and user feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 17 +- Dockerfile.worker | 60 ++ apps/web/app/api/auth/[...all]/route.ts | 4 + apps/web/app/api/chat/messages/route.ts | 224 +++++ apps/web/app/api/chat/route.ts | 338 ++++++++ .../api/chat/sessions/[sessionId]/route.ts | 108 +++ apps/web/app/api/chat/sessions/route.ts | 158 ++++ apps/web/app/api/chat/sources/route.ts | 37 + apps/web/app/api/dashboard/analytics/route.ts | 182 ++++ apps/web/app/api/documents/[id]/route.ts | 125 +++ apps/web/app/api/image/file/[...key]/route.ts | 56 ++ apps/web/app/api/image/route.ts | 97 +++ apps/web/app/api/images/assets/route.ts | 102 +++ apps/web/app/api/images/generate/route.ts | 66 ++ .../api/images/sessions/[sessionId]/route.ts | 41 + apps/web/app/api/images/sessions/route.ts | 126 +++ apps/web/app/api/jobs/[id]/route.ts | 58 ++ apps/web/app/api/keys/route.ts | 164 ++++ apps/web/app/api/search/route.ts | 150 ++++ apps/web/app/api/upload/route.ts | 150 ++++ apps/web/app/api/user/settings/route.ts | 112 +++ apps/web/app/chat/chat-header.tsx | 55 ++ apps/web/app/chat/chat-layout-client.tsx | 55 ++ apps/web/app/chat/chat-model-context.tsx | 54 ++ .../web/app/chat/chat-persistence-context.tsx | 46 + apps/web/app/chat/chat-runtime-provider.tsx | 313 +++++++ apps/web/app/chat/chat-search-context.tsx | 30 + apps/web/app/chat/layout.tsx | 37 + apps/web/app/chat/page.tsx | 49 ++ apps/web/app/chat/use-message-sources.tsx | 65 ++ apps/web/app/dashboard/data.json | 614 +++++++++++++ apps/web/app/dashboard/layout.tsx | 36 + apps/web/app/dashboard/loading.tsx | 65 ++ apps/web/app/dashboard/page.tsx | 55 ++ apps/web/app/dashboard/theme.css | 105 +++ apps/web/app/documents/layout.tsx | 35 + apps/web/app/documents/page.tsx | 113 +++ apps/web/app/favicon.ico | Bin 0 -> 25931 bytes apps/web/app/globals.css | 166 ++++ apps/web/app/image/layout.tsx | 34 + apps/web/app/image/page.tsx | 5 + apps/web/app/images/[sessionId]/page.tsx | 451 ++++++++++ apps/web/app/images/layout.tsx | 34 + apps/web/app/images/page.tsx | 401 +++++++++ apps/web/app/layout.tsx | 62 ++ apps/web/app/page.tsx | 40 + apps/web/app/sign-in/page.tsx | 107 +++ apps/web/app/sign-up/page.tsx | 224 +++++ apps/web/components.json | 21 + apps/web/components/analytics-data-table.tsx | 98 +++ apps/web/components/api-key-manager.tsx | 368 ++++++++ apps/web/components/app-sidebar.tsx | 208 +++++ .../components/assistant-ui/attachment.tsx | 235 +++++ .../components/assistant-ui/markdown-text.tsx | 228 +++++ .../assistant-ui/settings-dialog.tsx | 191 +++++ .../components/assistant-ui/source-card.tsx | 114 +++ apps/web/components/assistant-ui/thread.tsx | 477 +++++++++++ .../components/assistant-ui/tool-fallback.tsx | 46 + .../assistant-ui/tooltip-icon-button.tsx | 42 + apps/web/components/auth-buttons.tsx | 154 ++++ .../web/components/chart-area-interactive.tsx | 227 +++++ apps/web/components/chat-session-manager.tsx | 281 ++++++ apps/web/components/data-table.tsx | 807 ++++++++++++++++++ apps/web/components/document-search.tsx | 339 ++++++++ apps/web/components/file-upload.tsx | 318 +++++++ .../image-generation/image-chat.tsx | 114 +++ .../image-generation/image-display.tsx | 100 +++ .../components/landing/FeaturesSection.tsx | 98 +++ apps/web/components/landing/Footer.tsx | 101 +++ apps/web/components/landing/HeroSection.tsx | 64 ++ apps/web/components/landing/Navbar.tsx | 214 +++++ .../web/components/landing/PricingSection.tsx | 145 ++++ apps/web/components/landing/index.ts | 5 + apps/web/components/model-selector.tsx | 168 ++++ apps/web/components/nav-documents.tsx | 92 ++ apps/web/components/nav-main.tsx | 78 ++ apps/web/components/nav-secondary.tsx | 42 + apps/web/components/nav-user.tsx | 131 +++ apps/web/components/section-cards.tsx | 116 +++ .../components/session-history-sidebar.tsx | 244 ++++++ apps/web/components/site-header.tsx | 18 + apps/web/components/theme-provider.tsx | 9 + apps/web/components/theme-toggle.tsx | 56 ++ apps/web/components/ui/accordion.tsx | 66 ++ apps/web/components/ui/alert-dialog.tsx | 157 ++++ apps/web/components/ui/alert.tsx | 66 ++ apps/web/components/ui/aspect-ratio.tsx | 11 + apps/web/components/ui/aurora-background.tsx | 54 ++ apps/web/components/ui/avatar.tsx | 53 ++ apps/web/components/ui/badge.tsx | 46 + apps/web/components/ui/breadcrumb.tsx | 109 +++ apps/web/components/ui/button.tsx | 60 ++ apps/web/components/ui/calendar.tsx | 213 +++++ apps/web/components/ui/card.tsx | 92 ++ apps/web/components/ui/carousel.tsx | 241 ++++++ apps/web/components/ui/chart.tsx | 353 ++++++++ apps/web/components/ui/checkbox.tsx | 32 + apps/web/components/ui/collapsible.tsx | 33 + apps/web/components/ui/command.tsx | 184 ++++ apps/web/components/ui/context-menu.tsx | 252 ++++++ apps/web/components/ui/dialog.tsx | 143 ++++ apps/web/components/ui/drawer.tsx | 135 +++ apps/web/components/ui/dropdown-menu.tsx | 257 ++++++ apps/web/components/ui/flip-words.tsx | 84 ++ apps/web/components/ui/form.tsx | 167 ++++ .../ui/hero-with-text-and-two-button.tsx | 41 + apps/web/components/ui/hover-card.tsx | 44 + apps/web/components/ui/input-otp.tsx | 77 ++ apps/web/components/ui/input.tsx | 21 + apps/web/components/ui/label.tsx | 24 + apps/web/components/ui/menubar.tsx | 276 ++++++ apps/web/components/ui/navigation-menu.tsx | 168 ++++ apps/web/components/ui/pagination.tsx | 127 +++ apps/web/components/ui/popover.tsx | 48 ++ apps/web/components/ui/progress.tsx | 31 + apps/web/components/ui/radio-group.tsx | 45 + apps/web/components/ui/resizable.tsx | 56 ++ apps/web/components/ui/scroll-area.tsx | 58 ++ apps/web/components/ui/select.tsx | 185 ++++ apps/web/components/ui/separator.tsx | 28 + apps/web/components/ui/sheet.tsx | 139 +++ apps/web/components/ui/sidebar.tsx | 726 ++++++++++++++++ apps/web/components/ui/skeleton.tsx | 13 + apps/web/components/ui/slider.tsx | 63 ++ apps/web/components/ui/sonner.tsx | 25 + apps/web/components/ui/switch.tsx | 31 + apps/web/components/ui/table.tsx | 116 +++ apps/web/components/ui/tabs.tsx | 66 ++ apps/web/components/ui/textarea.tsx | 18 + apps/web/components/ui/toggle-group.tsx | 73 ++ apps/web/components/ui/toggle.tsx | 47 + apps/web/components/ui/tooltip.tsx | 61 ++ apps/web/hooks/use-mobile.ts | 19 + apps/web/hooks/use-session-history.ts | 13 + apps/web/lib/analytics/usage-logger.ts | 63 ++ apps/web/lib/api-keys-constants.ts | 7 + apps/web/lib/api-keys.ts | 67 ++ apps/web/lib/attachment-adapter.ts | 67 ++ apps/web/lib/auth-client.ts | 13 + apps/web/lib/auth.ts | 19 + apps/web/lib/crypto.ts | 49 ++ apps/web/lib/image-generation-service.ts | 569 ++++++++++++ apps/web/lib/s3.ts | 196 +++++ apps/web/lib/services/searxng.ts | 159 ++++ apps/web/lib/storage.ts | 59 ++ apps/web/lib/utils.ts | 6 + apps/web/next.config.ts | 17 + apps/web/node_modules/.bin/assistant-ui | 17 + apps/web/node_modules/.bin/browserslist | 17 + apps/web/node_modules/.bin/next | 17 + apps/web/node_modules/@ai-sdk/google | 1 + apps/web/node_modules/@ai-sdk/openai | 1 + apps/web/node_modules/@ai-sdk/react | 1 + apps/web/node_modules/@assistant-ui/react | 1 + .../node_modules/@assistant-ui/react-ai-sdk | 1 + .../node_modules/@assistant-ui/react-markdown | 1 + apps/web/node_modules/@aws-sdk/client-s3 | 1 + apps/web/node_modules/@dnd-kit/core | 1 + apps/web/node_modules/@dnd-kit/modifiers | 1 + apps/web/node_modules/@dnd-kit/sortable | 1 + apps/web/node_modules/@dnd-kit/utilities | 1 + apps/web/node_modules/@hookform/resolvers | 1 + .../node_modules/@radix-ui/react-accordion | 1 + .../node_modules/@radix-ui/react-alert-dialog | 1 + .../node_modules/@radix-ui/react-aspect-ratio | 1 + apps/web/node_modules/@radix-ui/react-avatar | 1 + .../web/node_modules/@radix-ui/react-checkbox | 1 + .../node_modules/@radix-ui/react-collapsible | 1 + .../node_modules/@radix-ui/react-context-menu | 1 + apps/web/node_modules/@radix-ui/react-dialog | 1 + .../@radix-ui/react-dropdown-menu | 1 + .../node_modules/@radix-ui/react-hover-card | 1 + apps/web/node_modules/@radix-ui/react-label | 1 + apps/web/node_modules/@radix-ui/react-menubar | 1 + .../@radix-ui/react-navigation-menu | 1 + apps/web/node_modules/@radix-ui/react-popover | 1 + .../web/node_modules/@radix-ui/react-progress | 1 + .../node_modules/@radix-ui/react-radio-group | 1 + .../node_modules/@radix-ui/react-scroll-area | 1 + apps/web/node_modules/@radix-ui/react-select | 1 + .../node_modules/@radix-ui/react-separator | 1 + apps/web/node_modules/@radix-ui/react-slider | 1 + apps/web/node_modules/@radix-ui/react-slot | 1 + apps/web/node_modules/@radix-ui/react-switch | 1 + apps/web/node_modules/@radix-ui/react-tabs | 1 + apps/web/node_modules/@radix-ui/react-toggle | 1 + .../node_modules/@radix-ui/react-toggle-group | 1 + apps/web/node_modules/@radix-ui/react-tooltip | 1 + apps/web/node_modules/@repo/db | 1 + apps/web/node_modules/@repo/types | 1 + apps/web/node_modules/@tabler/icons-react | 1 + apps/web/node_modules/@tanstack/react-table | 1 + apps/web/node_modules/ai | 1 + apps/web/node_modules/assistant-ui | 1 + apps/web/node_modules/better-auth | 1 + .../web/node_modules/class-variance-authority | 1 + apps/web/node_modules/clsx | 1 + apps/web/node_modules/cmdk | 1 + apps/web/node_modules/date-fns | 1 + apps/web/node_modules/dotenv | 1 + apps/web/node_modules/embla-carousel-react | 1 + apps/web/node_modules/input-otp | 1 + apps/web/node_modules/lucide-react | 1 + apps/web/node_modules/motion | 1 + apps/web/node_modules/next | 1 + apps/web/node_modules/next-themes | 1 + apps/web/node_modules/nuqs | 1 + apps/web/node_modules/react | 1 + apps/web/node_modules/react-day-picker | 1 + apps/web/node_modules/react-dom | 1 + apps/web/node_modules/react-dropzone | 1 + apps/web/node_modules/react-hook-form | 1 + apps/web/node_modules/react-resizable-panels | 1 + apps/web/node_modules/recharts | 1 + apps/web/node_modules/remark-gfm | 1 + apps/web/node_modules/sonner | 1 + apps/web/node_modules/tailwind-merge | 1 + apps/web/node_modules/vaul | 1 + apps/web/node_modules/zod | 1 + apps/web/node_modules/zustand | 1 + apps/web/package.json | 98 +++ apps/web/postcss.config.mjs | 5 + apps/web/public/codeguide-logo.png | Bin 0 -> 8908 bytes apps/web/public/file.svg | 1 + apps/web/public/globe.svg | 1 + apps/web/public/next.svg | 1 + apps/web/public/vercel.svg | 1 + apps/web/public/window.svg | 1 + apps/web/tsconfig.json | 20 + apps/web/types/analytics.ts | 34 + apps/worker/node_modules/.bin/marked | 17 + apps/worker/node_modules/@repo/db | 1 + apps/worker/node_modules/@repo/types | 1 + apps/worker/node_modules/ai | 1 + apps/worker/node_modules/bullmq | 1 + apps/worker/node_modules/cors | 1 + apps/worker/node_modules/dotenv | 1 + apps/worker/node_modules/express | 1 + apps/worker/node_modules/helmet | 1 + apps/worker/node_modules/ioredis | 1 + apps/worker/node_modules/marked | 1 + apps/worker/node_modules/morgan | 1 + apps/worker/node_modules/pdf-parse | 1 + apps/worker/node_modules/zod | 1 + apps/worker/package.json | 39 + apps/worker/src/index.ts | 73 ++ apps/worker/src/middleware/errorHandler.ts | 49 ++ apps/worker/src/routes/health.ts | 103 +++ apps/worker/src/routes/jobs.ts | 167 ++++ apps/worker/src/routes/search.ts | 203 +++++ apps/worker/src/services/documentProcessor.ts | 194 +++++ apps/worker/src/services/queue.ts | 124 +++ apps/worker/src/services/vectorSearch.ts | 245 ++++++ apps/worker/src/services/worker.ts | 69 ++ apps/worker/src/utils/fileUtils.ts | 123 +++ apps/worker/src/utils/logger.ts | 16 + apps/worker/tsconfig.json | 18 + docker-compose.yaml | 46 +- docker/postgres/init.sql | 3 + package.json | 130 +-- packages/db/drizzle.config.ts | 11 + packages/db/drizzle/0000_overjoyed_morlun.sql | 50 ++ .../0001_add_api_keys_and_chat_tables.sql | 57 ++ .../drizzle/0002_add_search_sources_table.sql | 13 + .../0003_add_ai_usage_analytics_table.sql | 23 + .../0004_add_image_generation_tables.sql | 14 + .../0005_expand_image_generation_system.sql | 92 ++ .../0006_add_document_processing_tables.sql | 59 ++ .../db/drizzle/0007_add_vector_indexes.sql | 36 + packages/db/drizzle/meta/0000_snapshot.json | 319 +++++++ packages/db/drizzle/meta/0001_snapshot.json | 117 +++ packages/db/drizzle/meta/_journal.json | 27 + packages/db/index.ts | 7 + packages/db/node_modules/drizzle-orm | 1 + packages/db/node_modules/pg | 1 + packages/db/node_modules/pgvector | 1 + packages/db/package.json | 25 + packages/db/schema/analytics.ts | 43 + packages/db/schema/api-keys.ts | 22 + packages/db/schema/auth.ts | 63 ++ packages/db/schema/chat.ts | 61 ++ packages/db/schema/documents.ts | 78 ++ packages/db/schema/images.ts | 163 ++++ packages/db/schema/index.ts | 6 + packages/tsconfig/base.json | 28 + packages/tsconfig/express.json | 42 + packages/tsconfig/nextjs.json | 38 + packages/tsconfig/package.json | 13 + packages/types/index.ts | 100 +++ packages/types/node_modules/zod | 1 + packages/types/package.json | 16 + pnpm-workspace.yaml | 3 + turbo.json | 42 + 293 files changed, 21698 insertions(+), 102 deletions(-) create mode 100644 Dockerfile.worker create mode 100644 apps/web/app/api/auth/[...all]/route.ts create mode 100644 apps/web/app/api/chat/messages/route.ts create mode 100644 apps/web/app/api/chat/route.ts create mode 100644 apps/web/app/api/chat/sessions/[sessionId]/route.ts create mode 100644 apps/web/app/api/chat/sessions/route.ts create mode 100644 apps/web/app/api/chat/sources/route.ts create mode 100644 apps/web/app/api/dashboard/analytics/route.ts create mode 100644 apps/web/app/api/documents/[id]/route.ts create mode 100644 apps/web/app/api/image/file/[...key]/route.ts create mode 100644 apps/web/app/api/image/route.ts create mode 100644 apps/web/app/api/images/assets/route.ts create mode 100644 apps/web/app/api/images/generate/route.ts create mode 100644 apps/web/app/api/images/sessions/[sessionId]/route.ts create mode 100644 apps/web/app/api/images/sessions/route.ts create mode 100644 apps/web/app/api/jobs/[id]/route.ts create mode 100644 apps/web/app/api/keys/route.ts create mode 100644 apps/web/app/api/search/route.ts create mode 100644 apps/web/app/api/upload/route.ts create mode 100644 apps/web/app/api/user/settings/route.ts create mode 100644 apps/web/app/chat/chat-header.tsx create mode 100644 apps/web/app/chat/chat-layout-client.tsx create mode 100644 apps/web/app/chat/chat-model-context.tsx create mode 100644 apps/web/app/chat/chat-persistence-context.tsx create mode 100644 apps/web/app/chat/chat-runtime-provider.tsx create mode 100644 apps/web/app/chat/chat-search-context.tsx create mode 100644 apps/web/app/chat/layout.tsx create mode 100644 apps/web/app/chat/page.tsx create mode 100644 apps/web/app/chat/use-message-sources.tsx create mode 100644 apps/web/app/dashboard/data.json create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/loading.tsx create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/dashboard/theme.css create mode 100644 apps/web/app/documents/layout.tsx create mode 100644 apps/web/app/documents/page.tsx create mode 100644 apps/web/app/favicon.ico create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/image/layout.tsx create mode 100644 apps/web/app/image/page.tsx create mode 100644 apps/web/app/images/[sessionId]/page.tsx create mode 100644 apps/web/app/images/layout.tsx create mode 100644 apps/web/app/images/page.tsx create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/sign-in/page.tsx create mode 100644 apps/web/app/sign-up/page.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/analytics-data-table.tsx create mode 100644 apps/web/components/api-key-manager.tsx create mode 100644 apps/web/components/app-sidebar.tsx create mode 100644 apps/web/components/assistant-ui/attachment.tsx create mode 100644 apps/web/components/assistant-ui/markdown-text.tsx create mode 100644 apps/web/components/assistant-ui/settings-dialog.tsx create mode 100644 apps/web/components/assistant-ui/source-card.tsx create mode 100644 apps/web/components/assistant-ui/thread.tsx create mode 100644 apps/web/components/assistant-ui/tool-fallback.tsx create mode 100644 apps/web/components/assistant-ui/tooltip-icon-button.tsx create mode 100644 apps/web/components/auth-buttons.tsx create mode 100644 apps/web/components/chart-area-interactive.tsx create mode 100644 apps/web/components/chat-session-manager.tsx create mode 100644 apps/web/components/data-table.tsx create mode 100644 apps/web/components/document-search.tsx create mode 100644 apps/web/components/file-upload.tsx create mode 100644 apps/web/components/image-generation/image-chat.tsx create mode 100644 apps/web/components/image-generation/image-display.tsx create mode 100644 apps/web/components/landing/FeaturesSection.tsx create mode 100644 apps/web/components/landing/Footer.tsx create mode 100644 apps/web/components/landing/HeroSection.tsx create mode 100644 apps/web/components/landing/Navbar.tsx create mode 100644 apps/web/components/landing/PricingSection.tsx create mode 100644 apps/web/components/landing/index.ts create mode 100644 apps/web/components/model-selector.tsx create mode 100644 apps/web/components/nav-documents.tsx create mode 100644 apps/web/components/nav-main.tsx create mode 100644 apps/web/components/nav-secondary.tsx create mode 100644 apps/web/components/nav-user.tsx create mode 100644 apps/web/components/section-cards.tsx create mode 100644 apps/web/components/session-history-sidebar.tsx create mode 100644 apps/web/components/site-header.tsx create mode 100644 apps/web/components/theme-provider.tsx create mode 100644 apps/web/components/theme-toggle.tsx create mode 100644 apps/web/components/ui/accordion.tsx create mode 100644 apps/web/components/ui/alert-dialog.tsx create mode 100644 apps/web/components/ui/alert.tsx create mode 100644 apps/web/components/ui/aspect-ratio.tsx create mode 100644 apps/web/components/ui/aurora-background.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/breadcrumb.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/calendar.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/carousel.tsx create mode 100644 apps/web/components/ui/chart.tsx create mode 100644 apps/web/components/ui/checkbox.tsx create mode 100644 apps/web/components/ui/collapsible.tsx create mode 100644 apps/web/components/ui/command.tsx create mode 100644 apps/web/components/ui/context-menu.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/drawer.tsx create mode 100644 apps/web/components/ui/dropdown-menu.tsx create mode 100644 apps/web/components/ui/flip-words.tsx create mode 100644 apps/web/components/ui/form.tsx create mode 100644 apps/web/components/ui/hero-with-text-and-two-button.tsx create mode 100644 apps/web/components/ui/hover-card.tsx create mode 100644 apps/web/components/ui/input-otp.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/menubar.tsx create mode 100644 apps/web/components/ui/navigation-menu.tsx create mode 100644 apps/web/components/ui/pagination.tsx create mode 100644 apps/web/components/ui/popover.tsx create mode 100644 apps/web/components/ui/progress.tsx create mode 100644 apps/web/components/ui/radio-group.tsx create mode 100644 apps/web/components/ui/resizable.tsx create mode 100644 apps/web/components/ui/scroll-area.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/components/ui/sidebar.tsx create mode 100644 apps/web/components/ui/skeleton.tsx create mode 100644 apps/web/components/ui/slider.tsx create mode 100644 apps/web/components/ui/sonner.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/components/ui/toggle-group.tsx create mode 100644 apps/web/components/ui/toggle.tsx create mode 100644 apps/web/components/ui/tooltip.tsx create mode 100644 apps/web/hooks/use-mobile.ts create mode 100644 apps/web/hooks/use-session-history.ts create mode 100644 apps/web/lib/analytics/usage-logger.ts create mode 100644 apps/web/lib/api-keys-constants.ts create mode 100644 apps/web/lib/api-keys.ts create mode 100644 apps/web/lib/attachment-adapter.ts create mode 100644 apps/web/lib/auth-client.ts create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/crypto.ts create mode 100644 apps/web/lib/image-generation-service.ts create mode 100644 apps/web/lib/s3.ts create mode 100644 apps/web/lib/services/searxng.ts create mode 100644 apps/web/lib/storage.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/next.config.ts create mode 100755 apps/web/node_modules/.bin/assistant-ui create mode 100755 apps/web/node_modules/.bin/browserslist create mode 100755 apps/web/node_modules/.bin/next create mode 120000 apps/web/node_modules/@ai-sdk/google create mode 120000 apps/web/node_modules/@ai-sdk/openai create mode 120000 apps/web/node_modules/@ai-sdk/react create mode 120000 apps/web/node_modules/@assistant-ui/react create mode 120000 apps/web/node_modules/@assistant-ui/react-ai-sdk create mode 120000 apps/web/node_modules/@assistant-ui/react-markdown create mode 120000 apps/web/node_modules/@aws-sdk/client-s3 create mode 120000 apps/web/node_modules/@dnd-kit/core create mode 120000 apps/web/node_modules/@dnd-kit/modifiers create mode 120000 apps/web/node_modules/@dnd-kit/sortable create mode 120000 apps/web/node_modules/@dnd-kit/utilities create mode 120000 apps/web/node_modules/@hookform/resolvers create mode 120000 apps/web/node_modules/@radix-ui/react-accordion create mode 120000 apps/web/node_modules/@radix-ui/react-alert-dialog create mode 120000 apps/web/node_modules/@radix-ui/react-aspect-ratio create mode 120000 apps/web/node_modules/@radix-ui/react-avatar create mode 120000 apps/web/node_modules/@radix-ui/react-checkbox create mode 120000 apps/web/node_modules/@radix-ui/react-collapsible create mode 120000 apps/web/node_modules/@radix-ui/react-context-menu create mode 120000 apps/web/node_modules/@radix-ui/react-dialog create mode 120000 apps/web/node_modules/@radix-ui/react-dropdown-menu create mode 120000 apps/web/node_modules/@radix-ui/react-hover-card create mode 120000 apps/web/node_modules/@radix-ui/react-label create mode 120000 apps/web/node_modules/@radix-ui/react-menubar create mode 120000 apps/web/node_modules/@radix-ui/react-navigation-menu create mode 120000 apps/web/node_modules/@radix-ui/react-popover create mode 120000 apps/web/node_modules/@radix-ui/react-progress create mode 120000 apps/web/node_modules/@radix-ui/react-radio-group create mode 120000 apps/web/node_modules/@radix-ui/react-scroll-area create mode 120000 apps/web/node_modules/@radix-ui/react-select create mode 120000 apps/web/node_modules/@radix-ui/react-separator create mode 120000 apps/web/node_modules/@radix-ui/react-slider create mode 120000 apps/web/node_modules/@radix-ui/react-slot create mode 120000 apps/web/node_modules/@radix-ui/react-switch create mode 120000 apps/web/node_modules/@radix-ui/react-tabs create mode 120000 apps/web/node_modules/@radix-ui/react-toggle create mode 120000 apps/web/node_modules/@radix-ui/react-toggle-group create mode 120000 apps/web/node_modules/@radix-ui/react-tooltip create mode 120000 apps/web/node_modules/@repo/db create mode 120000 apps/web/node_modules/@repo/types create mode 120000 apps/web/node_modules/@tabler/icons-react create mode 120000 apps/web/node_modules/@tanstack/react-table create mode 120000 apps/web/node_modules/ai create mode 120000 apps/web/node_modules/assistant-ui create mode 120000 apps/web/node_modules/better-auth create mode 120000 apps/web/node_modules/class-variance-authority create mode 120000 apps/web/node_modules/clsx create mode 120000 apps/web/node_modules/cmdk create mode 120000 apps/web/node_modules/date-fns create mode 120000 apps/web/node_modules/dotenv create mode 120000 apps/web/node_modules/embla-carousel-react create mode 120000 apps/web/node_modules/input-otp create mode 120000 apps/web/node_modules/lucide-react create mode 120000 apps/web/node_modules/motion create mode 120000 apps/web/node_modules/next create mode 120000 apps/web/node_modules/next-themes create mode 120000 apps/web/node_modules/nuqs create mode 120000 apps/web/node_modules/react create mode 120000 apps/web/node_modules/react-day-picker create mode 120000 apps/web/node_modules/react-dom create mode 120000 apps/web/node_modules/react-dropzone create mode 120000 apps/web/node_modules/react-hook-form create mode 120000 apps/web/node_modules/react-resizable-panels create mode 120000 apps/web/node_modules/recharts create mode 120000 apps/web/node_modules/remark-gfm create mode 120000 apps/web/node_modules/sonner create mode 120000 apps/web/node_modules/tailwind-merge create mode 120000 apps/web/node_modules/vaul create mode 120000 apps/web/node_modules/zod create mode 120000 apps/web/node_modules/zustand create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/public/codeguide-logo.png create mode 100644 apps/web/public/file.svg create mode 100644 apps/web/public/globe.svg create mode 100644 apps/web/public/next.svg create mode 100644 apps/web/public/vercel.svg create mode 100644 apps/web/public/window.svg create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/types/analytics.ts create mode 100755 apps/worker/node_modules/.bin/marked create mode 120000 apps/worker/node_modules/@repo/db create mode 120000 apps/worker/node_modules/@repo/types create mode 120000 apps/worker/node_modules/ai create mode 120000 apps/worker/node_modules/bullmq create mode 120000 apps/worker/node_modules/cors create mode 120000 apps/worker/node_modules/dotenv create mode 120000 apps/worker/node_modules/express create mode 120000 apps/worker/node_modules/helmet create mode 120000 apps/worker/node_modules/ioredis create mode 120000 apps/worker/node_modules/marked create mode 120000 apps/worker/node_modules/morgan create mode 120000 apps/worker/node_modules/pdf-parse create mode 120000 apps/worker/node_modules/zod create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/src/middleware/errorHandler.ts create mode 100644 apps/worker/src/routes/health.ts create mode 100644 apps/worker/src/routes/jobs.ts create mode 100644 apps/worker/src/routes/search.ts create mode 100644 apps/worker/src/services/documentProcessor.ts create mode 100644 apps/worker/src/services/queue.ts create mode 100644 apps/worker/src/services/vectorSearch.ts create mode 100644 apps/worker/src/services/worker.ts create mode 100644 apps/worker/src/utils/fileUtils.ts create mode 100644 apps/worker/src/utils/logger.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/drizzle/0000_overjoyed_morlun.sql create mode 100644 packages/db/drizzle/0001_add_api_keys_and_chat_tables.sql create mode 100644 packages/db/drizzle/0002_add_search_sources_table.sql create mode 100644 packages/db/drizzle/0003_add_ai_usage_analytics_table.sql create mode 100644 packages/db/drizzle/0004_add_image_generation_tables.sql create mode 100644 packages/db/drizzle/0005_expand_image_generation_system.sql create mode 100644 packages/db/drizzle/0006_add_document_processing_tables.sql create mode 100644 packages/db/drizzle/0007_add_vector_indexes.sql create mode 100644 packages/db/drizzle/meta/0000_snapshot.json create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 packages/db/drizzle/meta/_journal.json create mode 100644 packages/db/index.ts create mode 120000 packages/db/node_modules/drizzle-orm create mode 120000 packages/db/node_modules/pg create mode 120000 packages/db/node_modules/pgvector create mode 100644 packages/db/package.json create mode 100644 packages/db/schema/analytics.ts create mode 100644 packages/db/schema/api-keys.ts create mode 100644 packages/db/schema/auth.ts create mode 100644 packages/db/schema/chat.ts create mode 100644 packages/db/schema/documents.ts create mode 100644 packages/db/schema/images.ts create mode 100644 packages/db/schema/index.ts create mode 100644 packages/tsconfig/base.json create mode 100644 packages/tsconfig/express.json create mode 100644 packages/tsconfig/nextjs.json create mode 100644 packages/tsconfig/package.json create mode 100644 packages/types/index.ts create mode 120000 packages/types/node_modules/zod create mode 100644 packages/types/package.json create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json diff --git a/.env.example b/.env.example index 9a1ddf5..18b9f24 100644 --- a/.env.example +++ b/.env.example @@ -38,4 +38,19 @@ MINIO_BUCKET=images MINIO_REGION=us-east-1 # Google AI (for image generation) -GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here \ No newline at end of file +GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here + +# OpenAI Configuration (for embeddings) +OPENAI_API_KEY=your_openai_api_key_here + +# Redis Configuration (for BullMQ queue) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Worker Service Configuration +WORKER_API_URL=http://localhost:3001 +WORKER_CONCURRENCY=5 + +# Node Environment +NODE_ENV=development \ No newline at end of file diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..4f6e107 --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,60 @@ +# Use Node.js 18 Alpine as base image +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json pnpm-lock.yaml* package-lock.json* yarn.lock* ./ +RUN \ + if [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm i --frozen-lockfile ; \ + elif [ -f package-lock.json ]; then \ + npm ci; \ + elif [ -f yarn.lock ]; then \ + yarn install --frozen-lockfile; \ + else \ + echo "Lockfile not found." && exit 1; \ + fi + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the worker service +ENV NEXT_TELEMETRY_DISABLED 1 +RUN \ + if [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm run build --filter=@repo/worker ; \ + else \ + npm run build --workspace=@repo/worker; \ + fi + +# Production image, copy all the files and run the worker +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 worker + +# Copy the built application +COPY --from=builder /app/apps/worker/dist ./dist +COPY --from=builder --chown=worker:nodejs /app/apps/worker/node_modules ./node_modules +COPY --from=builder --chown=worker:nodejs /app/apps/worker/package.json ./package.json + +USER worker + +EXPOSE 3001 + +ENV PORT 3001 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..7866278 --- /dev/null +++ b/apps/web/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; // path to your auth file +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); \ No newline at end of file diff --git a/apps/web/app/api/chat/messages/route.ts b/apps/web/app/api/chat/messages/route.ts new file mode 100644 index 0000000..ef549ed --- /dev/null +++ b/apps/web/app/api/chat/messages/route.ts @@ -0,0 +1,224 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db, chatMessages, chatSessions, searchSources } from "@/db"; +import { eq, and, inArray } from "drizzle-orm"; +import { z } from "zod"; + +const createMessageSchema = z.object({ + sessionId: z.string().uuid(), + role: z.enum(["user", "assistant", "system"]), + content: z.string().min(1), + apiKeyId: z.string().uuid().optional(), + provider: z.string().optional(), + model: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const getMessagesSchema = z.object({ + sessionId: z.string().uuid(), + limit: z.string().regex(/^\d+$/).transform(Number).optional(), + offset: z.string().regex(/^\d+$/).transform(Number).optional(), +}); + +// GET /api/chat/messages - Get messages for a session +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const sessionIdParam = url.searchParams.get("sessionId"); + + console.log("GET /api/chat/messages - Raw params:", { + sessionId: sessionIdParam, + limit: url.searchParams.get("limit"), + offset: url.searchParams.get("offset"), + }); + + const queryParams = { + sessionId: sessionIdParam || undefined, + limit: url.searchParams.get("limit") || undefined, + offset: url.searchParams.get("offset") || undefined, + }; + + const validation = getMessagesSchema.safeParse(queryParams); + + if (!validation.success) { + const zodError = validation.error; + console.error("Message query validation failed:", { + queryParams, + errors: zodError.issues + }); + return NextResponse.json( + { error: "Invalid input", details: zodError.issues }, + { status: 400 } + ); + } + + const { sessionId, limit = 50, offset = 0 } = validation.data; + + // Verify session belongs to user + const [sessionExists] = await db + .select({ id: chatSessions.id }) + .from(chatSessions) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.userId, session.user.id) + ) + ) + .limit(1); + + if (!sessionExists) { + return NextResponse.json( + { error: "Session not found or unauthorized" }, + { status: 404 } + ); + } + + // Get messages for the session + const messages = await db + .select({ + id: chatMessages.id, + role: chatMessages.role, + content: chatMessages.content, + provider: chatMessages.provider, + model: chatMessages.model, + metadata: chatMessages.metadata, + createdAt: chatMessages.createdAt, + apiKeyId: chatMessages.apiKeyId, + }) + .from(chatMessages) + .where(eq(chatMessages.sessionId, sessionId)) + .orderBy(chatMessages.createdAt) + .limit(Math.min(limit, 100)) + .offset(Math.max(offset, 0)); + + // Get sources for all messages in this session + const messageIds = messages.map(m => m.id); + let sourcesMap: Record = {}; + + if (messageIds.length > 0) { + const allSources = await db + .select() + .from(searchSources) + .where(inArray(searchSources.messageId, messageIds)); + + // Group sources by messageId + allSources.forEach(source => { + if (!sourcesMap[source.messageId]) { + sourcesMap[source.messageId] = []; + } + sourcesMap[source.messageId].push(source); + }); + } + + // Add sources to each message + const messagesWithSources = messages.map(msg => ({ + ...msg, + sources: sourcesMap[msg.id] || [], + })); + + return NextResponse.json({ messages: messagesWithSources }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error fetching chat messages:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/chat/messages - Save a new message +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { sessionId, role, content, apiKeyId, provider, model, metadata } = createMessageSchema.parse(body); + + // Verify session belongs to user + const [sessionExists] = await db + .select({ id: chatSessions.id }) + .from(chatSessions) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.userId, session.user.id) + ) + ) + .limit(1); + + if (!sessionExists) { + return NextResponse.json( + { error: "Session not found or unauthorized" }, + { status: 404 } + ); + } + + // Insert the message + const [newMessage] = await db + .insert(chatMessages) + .values({ + sessionId, + userId: session.user.id, + role, + content, + apiKeyId: apiKeyId || null, + provider: provider || null, + model: model || null, + metadata: metadata || null, + }) + .returning({ + id: chatMessages.id, + role: chatMessages.role, + content: chatMessages.content, + provider: chatMessages.provider, + model: chatMessages.model, + metadata: chatMessages.metadata, + createdAt: chatMessages.createdAt, + }); + + // Update session's updatedAt timestamp + await db + .update(chatSessions) + .set({ updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + + return NextResponse.json({ + message: "Message saved successfully", + chatMessage: newMessage + }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error saving chat message:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts new file mode 100644 index 0000000..3cf8fde --- /dev/null +++ b/apps/web/app/api/chat/route.ts @@ -0,0 +1,338 @@ +import { auth } from "@/lib/auth"; +import { createOpenAI } from "@ai-sdk/openai"; +import { streamText, generateObject } from "ai"; +import { db, chatMessages, chatSessions, searchSources } from "@/db"; +import { getUserApiKey } from "@/lib/api-keys"; +import { eq } from "drizzle-orm"; +import { searxngService, type SearchResult } from "@/lib/services/searxng"; +import { z } from "zod"; +import { logAIUsage } from "@/lib/analytics/usage-logger"; + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30; + +// Schema for search query generation +const searchQuerySchema = z.object({ + query: z.string().describe("Generate a single optimized search query to find relevant information"), + reasoning: z.string().describe("Brief explanation of why this query is relevant"), +}); + +// Helper function to get favicon URL from a website URL +function getFaviconUrl(url: string): string | null { + try { + const urlObj = new URL(url); + // Use Google's favicon service as a reliable fallback + return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`; + } catch (error) { + console.error('Failed to parse URL for favicon:', url, error); + return null; + } +} + +export async function POST(request: Request) { + try { + // Get session from better-auth + const session = await auth.api.getSession({ + headers: request.headers + }); + + // Check if user is authenticated + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + + // Parse request body + const body = await request.json(); + const { messages, sessionId, apiKeyId, aiModel, useSearch } = body; + console.log('Received request body:', { + messagesLength: messages?.length, + sessionId, + aiModel, + useSearch, + firstMessage: messages?.[0] + }); + const modelId = aiModel + + // Determine which API key to use - user's key or environment key + let apiKey = process.env.OPENAI_API_KEY; + let usingUserKey = false; + + if (apiKeyId) { + const userApiKey = await getUserApiKey(session.user.id, 'openai'); + if (userApiKey) { + apiKey = userApiKey; + usingUserKey = true; + } + } + + // Check for API key + if (!apiKey) { + return new Response('OpenAI API key not configured', { status: 500 }); + } + + // Ensure we have a valid session ID for persistence + let currentSessionId = sessionId; + if (!currentSessionId && messages.length > 0) { + // Extract title from first message + let title = "New Chat"; + const firstMessage = messages[0]; + + if (firstMessage?.content) { + let contentText = ''; + if (typeof firstMessage.content === 'string') { + contentText = firstMessage.content; + } else if (Array.isArray(firstMessage.content)) { + // Handle assistant-ui format: [{ type: "text", text: "..." }] + contentText = firstMessage.content + .filter((part: { type: string; text?: string }) => part.type === 'text') + .map((part: { type: string; text?: string }) => part.text || '') + .join(''); + } + + if (contentText) { + title = contentText.substring(0, 50) + (contentText.length > 50 ? "..." : ""); + } + } + + // Create new session for first message + const [newSession] = await db + .insert(chatSessions) + .values({ + userId: session.user.id, + title, + }) + .returning({ id: chatSessions.id }); + currentSessionId = newSession.id; + + console.log('Created new chat session:', { sessionId: currentSessionId, title }); + } + + // Save user message to database if we have a session + if (currentSessionId && messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + console.log('Last message:', { role: lastMessage.role, contentType: typeof lastMessage.content, isArray: Array.isArray(lastMessage.content) }); + + if (lastMessage.role === 'user') { + // Extract text content from message content array + let contentText = ''; + if (typeof lastMessage.content === 'string') { + contentText = lastMessage.content; + } else if (Array.isArray(lastMessage.content)) { + // Handle assistant-ui format: [{ type: "text", text: "..." }] + contentText = lastMessage.content + .filter((part: { type: string; text?: string }) => part.type === 'text') + .map((part: { type: string; text?: string }) => part.text || '') + .join(''); + } + + console.log('Extracted content text:', { length: contentText.length, preview: contentText.substring(0, 100) }); + + if (contentText) { + const [savedMessage] = await db.insert(chatMessages).values({ + sessionId: currentSessionId, + userId: session.user.id, + role: 'user', + content: contentText, + apiKeyId: usingUserKey ? apiKeyId : null, + provider: 'openai', + model: modelId, + }).returning({ id: chatMessages.id }); + + console.log('Saved user message:', { messageId: savedMessage.id, sessionId: currentSessionId }); + } else { + console.error('Failed to extract content text from message:', lastMessage.content); + } + } + } + + // Create streaming response using AI SDK with selected model + // Create OpenAI provider with the appropriate API key + const openaiProvider = createOpenAI({ + apiKey: apiKey, + }); + + let assistantMessageSaved = false; + + // Validate and convert messages + if (!messages || !Array.isArray(messages)) { + console.error('Invalid messages format:', messages); + return new Response('Invalid messages format', { status: 400 }); + } + + console.log('Converting messages:', messages); + + // Convert ThreadMessageLike format to AI SDK format + const formattedMessages = messages.map((msg: any) => { + let content = msg.content; + + // If content is an array (ThreadMessageLike format), extract text + if (Array.isArray(content)) { + content = content + .filter((part: any) => part.type === 'text') + .map((part: any) => part.text) + .join(''); + } + + return { + role: msg.role, + content: content + }; + }); + + console.log('Formatted messages:', formattedMessages); + + // Handle search-augmented response if enabled + let searchContext = ""; + const collectedSearchSources: Array<{ url: string; title: string; snippet?: string }> = []; + + if (useSearch) { + try { + console.log('Search enabled, generating search queries...'); + + // Step 1: Use generateObject to create a search query from user message + const lastUserMessage = formattedMessages[formattedMessages.length - 1]; + const queryGeneration = await generateObject({ + model: openaiProvider("gpt-4.1"), + schema: searchQuerySchema, + prompt: `Generate a relevant search query to answer this question: "${lastUserMessage.content}"`, + }); + + console.log('Generated search query:', queryGeneration.object); + + // Step 2: Execute single search using SearXNG + if (queryGeneration.object.query) { + const searchResponse = await searxngService.search(queryGeneration.object.query); + + // Create a Map for compatibility with formatSearchContext + const searchResults = new Map(); + searchResults.set(queryGeneration.object.query, searchResponse); + + console.log("search results", searchResponse) + + // Step 3: Collect unique search sources from results + const seenUrls = new Set(); + searchResponse.results.slice(0, 5).forEach((result: SearchResult) => { + if (!seenUrls.has(result.url)) { + seenUrls.add(result.url); + collectedSearchSources.push({ + url: result.url, + title: result.title, + snippet: result.content, + }); + } + }); + + // Step 4: Format search results as context + searchContext = searxngService.formatSearchContext(searchResults); + console.log('Search context generated, length:', searchContext.length); + console.log('Collected search sources:', collectedSearchSources.length); + + // Add search context to the messages + formattedMessages.push({ + role: "system", + content: `Here is relevant information from web searches:\n\n${searchContext}\n\nUse this information to provide an accurate and helpful response. Cite sources when possible.`, + }); + } + } catch (error) { + console.error('Search error, falling back to direct chat:', error); + // Continue with regular chat if search fails + } + } + + console.log("formattedMessages",formattedMessages) + + const result = streamText({ + model: openaiProvider(modelId), + messages: formattedMessages, + temperature: 0.7, + onFinish: async (result) => { + // Prevent duplicate saves + if (assistantMessageSaved) { + console.log('Assistant message already saved, skipping'); + return; + } + assistantMessageSaved = true; + // Save assistant response to database + if (currentSessionId && result.text) { + const [savedMessage] = await db.insert(chatMessages).values({ + sessionId: currentSessionId, + userId: session.user.id, + role: 'assistant', + content: result.text, + apiKeyId: usingUserKey ? apiKeyId : null, + provider: 'openai', + model: modelId, + metadata: { + usage: result.usage, + finishReason: result.finishReason, + }, + }).returning({ id: chatMessages.id }); + + console.log('Saved assistant message:', { messageId: savedMessage.id, sessionId: currentSessionId, textLength: result.text.length }); + + // Log AI usage for analytics + await logAIUsage(session.user.id, useSearch ? "web_search" : "chat", { + sessionId: currentSessionId, + model: modelId, + provider: 'openai', + tokensUsed: result.usage?.totalTokens || 0, + inputTokens: result.usage?.promptTokens || 0, + outputTokens: result.usage?.completionTokens || 0, + messageCount: messages.length, + searchQuery: useSearch && collectedSearchSources.length > 0 ? formattedMessages.find(m => m.role === 'system')?.content?.substring(0, 100) : undefined, + resultCount: collectedSearchSources.length, + }); + + // Save search sources if any were collected + if (collectedSearchSources.length > 0) { + try { + const sourcesToInsert = collectedSearchSources.map(source => ({ + messageId: savedMessage.id, + url: source.url, + title: source.title, + snippet: source.snippet, + faviconUrl: getFaviconUrl(source.url), + })); + + await db.insert(searchSources).values(sourcesToInsert); + console.log('Saved search sources:', sourcesToInsert.length); + } catch (error) { + console.error('Failed to save search sources:', error); + // Don't fail the entire request if sources can't be saved + } + } + + // Update session timestamp + await db + .update(chatSessions) + .set({ updatedAt: new Date() }) + .where(eq(chatSessions.id, currentSessionId)); + } + }, + }); + + const response = result.toUIMessageStreamResponse(); + + // Add session ID to response headers + if (currentSessionId) { + response.headers.set('X-Session-ID', currentSessionId); + } + + return response; + } catch (error) { + console.error('Chat API error:', error); + + // Handle specific error types + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + return new Response('Rate limit exceeded. Please try again later.', { status: 429 }); + } + if (error.message.includes('quota')) { + return new Response('API quota exceeded. Please check your OpenAI billing.', { status: 402 }); + } + } + + return new Response('Internal server error', { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/chat/sessions/[sessionId]/route.ts b/apps/web/app/api/chat/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..fd0ea69 --- /dev/null +++ b/apps/web/app/api/chat/sessions/[sessionId]/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db, chatSessions } from "@/db"; +import { eq, and } from "drizzle-orm"; + +// DELETE /api/chat/sessions/[sessionId] - Delete a chat session +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { sessionId } = await params; + + // Validate UUID format + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(sessionId)) { + return NextResponse.json({ error: "Invalid session ID format" }, { status: 400 }); + } + + // Delete the session (messages will be deleted by CASCADE) + const result = await db + .delete(chatSessions) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.userId, session.user.id) + ) + ) + .returning({ id: chatSessions.id }); + + if (result.length === 0) { + return NextResponse.json( + { error: "Session not found or unauthorized" }, + { status: 404 } + ); + } + + return NextResponse.json({ message: "Session deleted successfully" }); + } catch (error) { + console.error("Error deleting chat session:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// GET /api/chat/sessions/[sessionId] - Get single session details +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { sessionId } = await params; + + // Validate UUID format + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(sessionId)) { + return NextResponse.json({ error: "Invalid session ID format" }, { status: 400 }); + } + + // Get session details + const [sessionData] = await db + .select({ + id: chatSessions.id, + title: chatSessions.title, + createdAt: chatSessions.createdAt, + updatedAt: chatSessions.updatedAt, + }) + .from(chatSessions) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.userId, session.user.id) + ) + ) + .limit(1); + + if (!sessionData) { + return NextResponse.json( + { error: "Session not found or unauthorized" }, + { status: 404 } + ); + } + + return NextResponse.json({ session: sessionData }); + } catch (error) { + console.error("Error fetching chat session:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/chat/sessions/route.ts b/apps/web/app/api/chat/sessions/route.ts new file mode 100644 index 0000000..1c24384 --- /dev/null +++ b/apps/web/app/api/chat/sessions/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db, chatSessions, chatMessages } from "@/db"; +import { eq, desc, sql } from "drizzle-orm"; +import { z } from "zod"; + +const createSessionSchema = z.object({ + title: z.string().min(1).max(255).optional(), +}); + +const updateSessionSchema = z.object({ + sessionId: z.string().uuid(), + title: z.string().min(1).max(255), +}); + +// GET /api/chat/sessions - List user's chat sessions +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100); + const offset = Math.max(parseInt(url.searchParams.get("offset") || "0"), 0); + + // Get sessions with message count and last message time + const sessions = await db + .select({ + id: chatSessions.id, + title: chatSessions.title, + createdAt: chatSessions.createdAt, + updatedAt: chatSessions.updatedAt, + messageCount: sql`count(${chatMessages.id})`.as("messageCount"), + lastMessageAt: sql`max(${chatMessages.createdAt})`.as("lastMessageAt"), + }) + .from(chatSessions) + .leftJoin(chatMessages, eq(chatSessions.id, chatMessages.sessionId)) + .where(eq(chatSessions.userId, session.user.id)) + .groupBy(chatSessions.id, chatSessions.title, chatSessions.createdAt, chatSessions.updatedAt) + .orderBy(desc(sql`max(${chatMessages.createdAt})`), desc(chatSessions.updatedAt)) + .limit(limit) + .offset(offset); + + return NextResponse.json({ sessions }); + } catch (error) { + console.error("Error fetching chat sessions:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/chat/sessions - Create new chat session +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { title } = createSessionSchema.parse(body); + + const [newSession] = await db + .insert(chatSessions) + .values({ + userId: session.user.id, + title: title || "New Chat", + }) + .returning({ + id: chatSessions.id, + title: chatSessions.title, + createdAt: chatSessions.createdAt, + updatedAt: chatSessions.updatedAt, + }); + + return NextResponse.json({ + message: "Chat session created successfully", + session: newSession + }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error creating chat session:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PATCH /api/chat/sessions - Update session title +export async function PATCH(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { sessionId, title } = updateSessionSchema.parse(body); + + const [updatedSession] = await db + .update(chatSessions) + .set({ + title, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, sessionId)) + .returning({ + id: chatSessions.id, + title: chatSessions.title, + updatedAt: chatSessions.updatedAt, + }); + + if (!updatedSession) { + return NextResponse.json( + { error: "Session not found or unauthorized" }, + { status: 404 } + ); + } + + return NextResponse.json({ + message: "Session updated successfully", + session: updatedSession + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error updating chat session:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/chat/sources/route.ts b/apps/web/app/api/chat/sources/route.ts new file mode 100644 index 0000000..edcc3d1 --- /dev/null +++ b/apps/web/app/api/chat/sources/route.ts @@ -0,0 +1,37 @@ +import { auth } from "@/lib/auth"; +import { db, searchSources } from "@/db"; +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + // Get session from better-auth + const session = await auth.api.getSession({ + headers: request.headers + }); + + // Check if user is authenticated + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + // Get message ID from query params + const { searchParams } = new URL(request.url); + const messageId = searchParams.get('messageId'); + + if (!messageId) { + return new Response('Message ID is required', { status: 400 }); + } + + // Fetch sources for the message + const sources = await db + .select() + .from(searchSources) + .where(eq(searchSources.messageId, messageId)); + + return NextResponse.json({ sources }); + } catch (error) { + console.error('Failed to fetch search sources:', error); + return new Response('Internal server error', { status: 500 }); + } +} diff --git a/apps/web/app/api/dashboard/analytics/route.ts b/apps/web/app/api/dashboard/analytics/route.ts new file mode 100644 index 0000000..a9f4cfa --- /dev/null +++ b/apps/web/app/api/dashboard/analytics/route.ts @@ -0,0 +1,182 @@ +import { auth } from "@/lib/auth"; +import { db, aiUsage } from "@/db"; +import { eq, sql, desc, and, gte } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + // Get session from better-auth + const session = await auth.api.getSession({ + headers: request.headers + }); + + // Check if user is authenticated + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const userId = session.user.id; + + // Calculate date ranges + const now = new Date(); + const last30Days = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const last7Days = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const last90Days = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + + // Get total usage counts by feature type + const usageByFeature = await db + .select({ + featureType: aiUsage.featureType, + count: sql`cast(count(*) as int)`, + }) + .from(aiUsage) + .where(eq(aiUsage.userId, userId)) + .groupBy(aiUsage.featureType); + + const chatCount = usageByFeature.find(u => u.featureType === 'chat')?.count || 0; + const searchCount = usageByFeature.find(u => u.featureType === 'web_search')?.count || 0; + const imageCount = usageByFeature.find(u => u.featureType === 'image_generation')?.count || 0; + const totalUsage = chatCount + searchCount + imageCount; + + // Get usage counts from last 30 days for comparison + const last30DaysUsage = await db + .select({ + count: sql`cast(count(*) as int)`, + }) + .from(aiUsage) + .where( + and( + eq(aiUsage.userId, userId), + gte(aiUsage.createdAt, last30Days) + ) + ); + + const last30DaysCount = last30DaysUsage[0]?.count || 0; + + // Get usage counts from previous 30 days for trend calculation + const previous30Days = new Date(last30Days.getTime() - 30 * 24 * 60 * 60 * 1000); + const previous30DaysUsage = await db + .select({ + count: sql`cast(count(*) as int)`, + }) + .from(aiUsage) + .where( + and( + eq(aiUsage.userId, userId), + gte(aiUsage.createdAt, previous30Days), + sql`${aiUsage.createdAt} < ${last30Days}` + ) + ); + + const previous30DaysCount = previous30DaysUsage[0]?.count || 0; + + // Calculate trend percentage + const trendPercentage = previous30DaysCount > 0 + ? ((last30DaysCount - previous30DaysCount) / previous30DaysCount) * 100 + : last30DaysCount > 0 ? 100 : 0; + + // Get daily usage data for the last 90 days for the chart + const dailyUsage = await db + .select({ + date: sql`DATE(${aiUsage.createdAt})`, + featureType: aiUsage.featureType, + count: sql`cast(count(*) as int)`, + }) + .from(aiUsage) + .where( + and( + eq(aiUsage.userId, userId), + gte(aiUsage.createdAt, last90Days) + ) + ) + .groupBy(sql`DATE(${aiUsage.createdAt})`, aiUsage.featureType) + .orderBy(sql`DATE(${aiUsage.createdAt})`); + + // Transform daily usage into chart format + const chartDataMap = new Map(); + + // Initialize all dates in the range with 0 counts + for (let i = 0; i < 90; i++) { + const date = new Date(last90Days); + date.setDate(date.getDate() + i); + const dateStr = date.toISOString().split('T')[0]; + chartDataMap.set(dateStr, { date: dateStr, chat: 0, web_search: 0, image_generation: 0 }); + } + + // Fill in actual data + dailyUsage.forEach(row => { + const existing = chartDataMap.get(row.date); + if (existing) { + if (row.featureType === 'chat') { + existing.chat = row.count; + } else if (row.featureType === 'web_search') { + existing.web_search = row.count; + } else if (row.featureType === 'image_generation') { + existing.image_generation = row.count; + } + } + }); + + const chartData = Array.from(chartDataMap.values()); + + // Get recent usage for the table + const recentUsage = await db + .select({ + id: aiUsage.id, + featureType: aiUsage.featureType, + metadata: aiUsage.metadata, + createdAt: aiUsage.createdAt, + }) + .from(aiUsage) + .where(eq(aiUsage.userId, userId)) + .orderBy(desc(aiUsage.createdAt)) + .limit(50); + + // Calculate average tokens per usage + const totalTokens = recentUsage.reduce((sum, usage) => { + const tokensUsed = (usage.metadata as any)?.tokensUsed || 0; + return sum + tokensUsed; + }, 0); + const avgTokens = totalUsage > 0 ? Math.round(totalTokens / totalUsage) : 0; + + // Calculate average input/output tokens + const totalInputTokens = recentUsage.reduce((sum, usage) => { + const inputTokens = (usage.metadata as any)?.inputTokens || 0; + return sum + inputTokens; + }, 0); + const totalOutputTokens = recentUsage.reduce((sum, usage) => { + const outputTokens = (usage.metadata as any)?.outputTokens || 0; + return sum + outputTokens; + }, 0); + const avgInputTokens = totalUsage > 0 ? Math.round(totalInputTokens / totalUsage) : 0; + const avgOutputTokens = totalUsage > 0 ? Math.round(totalOutputTokens / totalUsage) : 0; + + // Format response + return NextResponse.json({ + summary: { + totalUsage, + chatCount, + searchCount, + imageCount, + avgTokens, + avgInputTokens, + avgOutputTokens, + last30DaysCount, + trendPercentage: Math.round(trendPercentage * 10) / 10, // Round to 1 decimal + }, + chartData, + recentUsage: recentUsage.map(usage => ({ + id: usage.id, + type: usage.featureType, + model: (usage.metadata as any)?.model || 'N/A', + tokens: (usage.metadata as any)?.tokensUsed || 0, + inputTokens: (usage.metadata as any)?.inputTokens || 0, + outputTokens: (usage.metadata as any)?.outputTokens || 0, + timestamp: usage.createdAt, + })), + }); + } catch (error) { + console.error('Analytics API error:', error); + return new Response('Internal server error', { status: 500 }); + } +} diff --git a/apps/web/app/api/documents/[id]/route.ts b/apps/web/app/api/documents/[id]/route.ts new file mode 100644 index 0000000..c84d456 --- /dev/null +++ b/apps/web/app/api/documents/[id]/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const WORKER_API_URL = process.env.WORKER_API_URL || 'http://localhost:3001'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const documentId = params.id; + const userId = request.headers.get('x-user-id'); + + if (!documentId) { + return NextResponse.json( + { success: false, error: 'Document ID is required' }, + { status: 400 } + ); + } + + if (!userId) { + return NextResponse.json( + { success: false, error: 'User ID required' }, + { status: 401 } + ); + } + + // Call worker service API + const response = await fetch(`${WORKER_API_URL}/api/search/documents/${documentId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Failed to get document' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json(result); + + } catch (error) { + console.error('Get document error:', error); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const documentId = params.id; + const userId = request.headers.get('x-user-id'); + + if (!documentId) { + return NextResponse.json( + { success: false, error: 'Document ID is required' }, + { status: 400 } + ); + } + + if (!userId) { + return NextResponse.json( + { success: false, error: 'User ID required' }, + { status: 401 } + ); + } + + // Call worker service API + const response = await fetch(`${WORKER_API_URL}/api/search/documents/${documentId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Failed to delete document' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json(result); + + } catch (error) { + console.error('Delete document error:', error); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/image/file/[...key]/route.ts b/apps/web/app/api/image/file/[...key]/route.ts new file mode 100644 index 0000000..4fa8eb8 --- /dev/null +++ b/apps/web/app/api/image/file/[...key]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest } from "next/server"; +import { getImageObject } from "@/lib/s3"; + +export const runtime = "nodejs"; + +export async function GET( + _request: NextRequest, + context: { params: { key?: string[] } } +) { + const { key: keySegments } = context.params; + + if (!keySegments || keySegments.length === 0) { + return new Response( + JSON.stringify({ error: "Image key is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const key = keySegments.map((segment) => decodeURIComponent(segment)).join("/"); + + try { + const { buffer, contentType, contentLength } = await getImageObject(key); + + const headers: Record = { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }; + + if (typeof contentLength === "number") { + headers["Content-Length"] = contentLength.toString(); + } + + const filename = key.split("/").pop(); + if (filename) { + headers["Content-Disposition"] = `inline; filename="${filename}"`; + } + + return new Response(buffer, { + status: 200, + headers, + }); + } catch (error) { + console.error("Failed to fetch image from storage:", error); + + return new Response( + JSON.stringify({ error: "Image not found" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/apps/web/app/api/image/route.ts b/apps/web/app/api/image/route.ts new file mode 100644 index 0000000..51997f0 --- /dev/null +++ b/apps/web/app/api/image/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/lib/auth"; +import { + createImageSession, + runImageGeneration, +} from "@/lib/image-generation-service"; +import { getPublicUrl } from "@/lib/storage"; + +// Allow up to 60 seconds for image generation +export const maxDuration = 60; + +// Schema for request validation +const imageGenerationSchema = z.object({ + prompt: z.string().min(1).max(1000), +}); + +export async function POST(request: Request) { + try { + // Get session from better-auth + const session = await auth.api.getSession({ + headers: request.headers + }); + + // Check if user is authenticated + if (!session || !session.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse and validate request body + const body = await request.json(); + const validation = imageGenerationSchema.safeParse(body); + + if (!validation.success) { + return NextResponse.json( + { error: "Invalid prompt", details: validation.error.errors }, + { status: 400 } + ); + } + + const { prompt } = validation.data; + + const imageSession = await createImageSession({ + userId: session.user.id, + provider: "google", + model: "gemini-2.5-flash-image-preview", + seedPrompt: prompt, + }); + + const generation = await runImageGeneration({ + sessionId: imageSession.id, + userId: session.user.id, + prompt, + provider: "google", + model: "gemini-2.5-flash-image-preview", + }); + + return NextResponse.json({ + success: true, + image: { + id: generation.outputAsset.id, + url: getPublicUrl(generation.outputAsset.storageKey), + prompt, + createdAt: generation.generation.completedAt ?? new Date(), + storageKey: generation.outputAsset.storageKey, + storageUrl: generation.outputAsset.storageUrl, + sessionId: generation.session.id, + generationId: generation.generation.id, + }, + }); + + } catch (error) { + console.error('Image generation API error:', error); + + // Handle specific error types + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + return NextResponse.json( + { error: 'Rate limit exceeded. Please try again later.' }, + { status: 429 } + ); + } + if (error.message.includes('quota')) { + return NextResponse.json( + { error: 'API quota exceeded. Please check your billing.' }, + { status: 402 } + ); + } + } + + return NextResponse.json({ + error: 'Internal server error', + details: error instanceof Error ? error.message : "Unknown error" + }, { status: 500 }); + } +} diff --git a/apps/web/app/api/images/assets/route.ts b/apps/web/app/api/images/assets/route.ts new file mode 100644 index 0000000..6ec81a1 --- /dev/null +++ b/apps/web/app/api/images/assets/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server"; + +import { db, imageAssets, imageGenerations, imageSessions } from "@/db"; +import { auth } from "@/lib/auth"; +import { getPublicUrl, putImage } from "@/lib/storage"; +import { eq } from "drizzle-orm"; + +const allowedRoles = new Set(["input", "mask"]); + +export const maxDuration = 60; + +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + const generationId = formData.get("generationId"); + const role = formData.get("role"); + + if (!(file instanceof Blob)) { + return NextResponse.json({ error: "File is required" }, { status: 400 }); + } + + if (typeof generationId !== "string" || !generationId) { + return NextResponse.json({ error: "generationId is required" }, { status: 400 }); + } + + if (typeof role !== "string" || !allowedRoles.has(role)) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + + const result = await db + .select({ + generation: imageGenerations, + session: imageSessions, + }) + .from(imageGenerations) + .innerJoin( + imageSessions, + eq(imageGenerations.sessionId, imageSessions.id), + ) + .where(eq(imageGenerations.id, generationId)) + .limit(1); + + const record = result[0]; + + if (!record || record.session.userId !== session.user.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const contentType = file.type || "image/png"; + + const upload = await putImage({ + buffer, + contentType, + directory: `sessions/${record.session.id}/uploads`, + }); + + const [asset] = await db + .insert(imageAssets) + .values({ + generationId, + role: role as "input" | "mask", + storageProvider: upload.provider, + storageBucket: upload.bucket, + storageKey: upload.key, + storageUrl: upload.storageUrl, + mimeType: contentType, + sizeBytes: buffer.length, + }) + .returning(); + + if (role === "input") { + await db + .update(imageGenerations) + .set({ sourceAssetId: asset.id }) + .where(eq(imageGenerations.id, generationId)); + } else if (role === "mask") { + await db + .update(imageGenerations) + .set({ maskAssetId: asset.id }) + .where(eq(imageGenerations.id, generationId)); + } + + return NextResponse.json({ + asset: { + ...asset, + url: getPublicUrl(asset.storageKey), + }, + }); + } catch (error) { + console.error("Failed to upload asset", error); + const message = error instanceof Error ? error.message : "Upload failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/app/api/images/generate/route.ts b/apps/web/app/api/images/generate/route.ts new file mode 100644 index 0000000..694c1bc --- /dev/null +++ b/apps/web/app/api/images/generate/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/lib/auth"; +import { runImageGeneration } from "@/lib/image-generation-service"; +import { getPublicUrl } from "@/lib/storage"; + +const generateSchema = z.object({ + sessionId: z.string().min(1), + prompt: z.string().min(1).max(2000), + provider: z.string().min(1).max(120).optional(), + model: z.string().min(1).max(120).optional(), + parentGenerationId: z.string().min(1).optional(), + sourceAssetId: z.string().min(1).optional(), + maskAssetId: z.string().min(1).optional(), + negativePrompt: z.string().min(1).max(2000).optional(), + params: z.record(z.any()).optional(), +}); + +export const maxDuration = 60; + +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await request.json(); + const parsed = generateSchema.safeParse(payload); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const result = await runImageGeneration({ + sessionId: parsed.data.sessionId, + userId: session.user.id, + prompt: parsed.data.prompt, + provider: parsed.data.provider, + model: parsed.data.model, + parentGenerationId: parsed.data.parentGenerationId, + sourceAssetId: parsed.data.sourceAssetId, + maskAssetId: parsed.data.maskAssetId, + params: parsed.data.params || null, + negativePrompt: parsed.data.negativePrompt || null, + }); + + return NextResponse.json({ + session: result.session, + generation: result.generation, + outputAsset: { + ...result.outputAsset, + url: getPublicUrl(result.outputAsset.storageKey), + }, + }); + } catch (error) { + console.error("Failed to generate image", error); + const message = error instanceof Error ? error.message : "Generation failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/app/api/images/sessions/[sessionId]/route.ts b/apps/web/app/api/images/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..85279d1 --- /dev/null +++ b/apps/web/app/api/images/sessions/[sessionId]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/lib/auth"; +import { getImageSession } from "@/lib/image-generation-service"; +import { getPublicUrl } from "@/lib/storage"; + +export async function GET( + request: Request, + { params }: { params: { sessionId: string } }, +) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await getImageSession(session.user.id, params.sessionId); + + if (!result) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ + session: result.session, + generations: result.generations.map((generation) => ({ + ...generation, + assets: generation.assets.map((asset) => ({ + ...asset, + url: getPublicUrl(asset.storageKey), + })), + })), + }); + } catch (error) { + console.error("Failed to fetch image session", error); + return NextResponse.json( + { error: "Failed to fetch session" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/images/sessions/route.ts b/apps/web/app/api/images/sessions/route.ts new file mode 100644 index 0000000..fe051f0 --- /dev/null +++ b/apps/web/app/api/images/sessions/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/lib/auth"; +import { + createImageSession, + listImageSessions, + runImageGeneration, +} from "@/lib/image-generation-service"; +import { getPublicUrl } from "@/lib/storage"; + +const createSessionSchema = z.object({ + provider: z.string().min(1).max(120).optional(), + model: z.string().min(1).max(120).optional(), + prompt: z.string().min(1).max(2000).optional(), + negativePrompt: z.string().min(1).max(2000).optional(), + params: z.record(z.any()).optional(), +}); + +export const maxDuration = 60; + +export async function GET(request: Request) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const limit = clampNumber(searchParams.get("limit"), 1, 50, 12); + const offset = clampNumber(searchParams.get("offset"), 0, 1000, 0); + + const sessions = await listImageSessions(session.user.id, { limit, offset }); + + return NextResponse.json({ + sessions: sessions.map((item) => ({ + ...item, + thumbnail: item.thumbnail + ? { + ...item.thumbnail, + url: getPublicUrl(item.thumbnail.storageKey), + } + : null, + })), + }); + } catch (error) { + console.error("Failed to list image sessions", error); + return NextResponse.json( + { error: "Failed to load image sessions" }, + { status: 500 }, + ); + } +} + +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await request.json(); + const parsed = createSessionSchema.safeParse(payload); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { prompt, params, negativePrompt, ...sessionData } = parsed.data; + + const newSession = await createImageSession({ + userId: session.user.id, + provider: sessionData.provider, + model: sessionData.model, + seedPrompt: prompt || null, + }); + + if (prompt) { + const generationResult = await runImageGeneration({ + sessionId: newSession.id, + userId: session.user.id, + prompt, + provider: sessionData.provider, + model: sessionData.model, + params: params || null, + negativePrompt: negativePrompt || null, + }); + + return NextResponse.json( + { + session: generationResult.session, + generation: generationResult.generation, + outputAsset: { + ...generationResult.outputAsset, + url: getPublicUrl(generationResult.outputAsset.storageKey), + }, + }, + { status: 201 }, + ); + } + + return NextResponse.json({ session: newSession }, { status: 201 }); + } catch (error) { + console.error("Failed to create image session", error); + const message = + error instanceof Error ? error.message : "Failed to create session"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +function clampNumber( + value: string | null, + min: number, + max: number, + fallback: number, +) { + if (!value) return fallback; + const parsed = Number(value); + if (Number.isNaN(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} diff --git a/apps/web/app/api/jobs/[id]/route.ts b/apps/web/app/api/jobs/[id]/route.ts new file mode 100644 index 0000000..cbf823b --- /dev/null +++ b/apps/web/app/api/jobs/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const WORKER_API_URL = process.env.WORKER_API_URL || 'http://localhost:3001'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const jobId = params.id; + + if (!jobId) { + return NextResponse.json( + { success: false, error: 'Job ID is required' }, + { status: 400 } + ); + } + + // Call worker service API + const response = await fetch(`${WORKER_API_URL}/api/jobs/${jobId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Failed to get job status' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json({ + success: true, + job: result.job, + }); + + } catch (error) { + console.error('Job status error:', error); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/keys/route.ts b/apps/web/app/api/keys/route.ts new file mode 100644 index 0000000..d4d79fe --- /dev/null +++ b/apps/web/app/api/keys/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db, userApiKeys } from "@/db"; +import { encryptApiKey, decryptApiKey, hashApiKey } from "@/lib/crypto"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; + +const createApiKeySchema = z.object({ + provider: z.enum(["openai", "anthropic", "google", "cohere", "mistral"]), + apiKey: z.string().min(1, "API key is required"), + keyName: z.string().optional(), +}); + +const deleteApiKeySchema = z.object({ + keyId: z.string().uuid(), +}); + +// GET /api/keys - List user's API keys +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const keys = await db + .select({ + id: userApiKeys.id, + provider: userApiKeys.provider, + keyName: userApiKeys.keyName, + isActive: userApiKeys.isActive, + createdAt: userApiKeys.createdAt, + hashedKey: userApiKeys.encryptedKey, // We'll show a hash for identification + }) + .from(userApiKeys) + .where(eq(userApiKeys.userId, session.user.id)) + .orderBy(userApiKeys.createdAt); + + // Transform encrypted keys to show only a hash for security + const maskedKeys = keys.map((key) => ({ + ...key, + maskedKey: key.hashedKey.split(':')[0].substring(0, 8) + '...', + hashedKey: undefined, + })); + + return NextResponse.json({ keys: maskedKeys }); + } catch (error) { + console.error("Error fetching API keys:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/keys - Create new API key +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { provider, apiKey, keyName } = createApiKeySchema.parse(body); + + // Encrypt the API key + const encryptedKey = encryptApiKey(apiKey); + + // Create the key record + const [newKey] = await db + .insert(userApiKeys) + .values({ + userId: session.user.id, + provider, + encryptedKey, + keyName: keyName || `${provider.charAt(0).toUpperCase() + provider.slice(1)} API Key`, + isActive: "true", + }) + .returning({ + id: userApiKeys.id, + provider: userApiKeys.provider, + keyName: userApiKeys.keyName, + isActive: userApiKeys.isActive, + createdAt: userApiKeys.createdAt, + }); + + return NextResponse.json({ + message: "API key created successfully", + key: { + ...newKey, + maskedKey: hashApiKey(apiKey), + } + }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error creating API key:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// DELETE /api/keys - Delete API key +export async function DELETE(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { keyId } = deleteApiKeySchema.parse(body); + + // Delete the key (only if it belongs to the user) + const result = await db + .delete(userApiKeys) + .where( + and( + eq(userApiKeys.id, keyId), + eq(userApiKeys.userId, session.user.id) + ) + ) + .returning({ id: userApiKeys.id }); + + if (result.length === 0) { + return NextResponse.json( + { error: "API key not found or unauthorized" }, + { status: 404 } + ); + } + + return NextResponse.json({ message: "API key deleted successfully" }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error deleting API key:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts new file mode 100644 index 0000000..6382656 --- /dev/null +++ b/apps/web/app/api/search/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const WORKER_API_URL = process.env.WORKER_API_URL || 'http://localhost:3001'; + +const searchSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().min(1).max(100).optional().default(10), + threshold: z.number().min(0).max(1).optional().default(0.7), +}); + +export async function POST(request: NextRequest) { + try { + const userId = request.headers.get('x-user-id'); + if (!userId) { + return NextResponse.json( + { success: false, error: 'User ID required' }, + { status: 401 } + ); + } + + const body = await request.json(); + const validatedData = searchSchema.parse(body); + + // Call worker service API + const response = await fetch(`${WORKER_API_URL}/api/search/similar`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + body: JSON.stringify({ + query: validatedData.query, + limit: validatedData.limit, + threshold: validatedData.threshold, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Search failed' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json({ + success: true, + query: result.query, + results: result.results, + }); + + } catch (error) { + console.error('Search error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: error.errors, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + try { + const userId = request.headers.get('x-user-id'); + if (!userId) { + return NextResponse.json( + { success: false, error: 'User ID required' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const endpoint = searchParams.get('endpoint') || 'documents'; + + let workerEndpoint: string; + + switch (endpoint) { + case 'documents': + workerEndpoint = `${WORKER_API_URL}/api/search/documents?${searchParams.toString()}`; + break; + case 'stats': + workerEndpoint = `${WORKER_API_URL}/api/search/stats`; + break; + default: + return NextResponse.json( + { success: false, error: 'Invalid endpoint' }, + { status: 400 } + ); + } + + // Call worker service API + const response = await fetch(workerEndpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Request failed' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + return NextResponse.json(result); + + } catch (error) { + console.error('API request error:', error); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/upload/route.ts b/apps/web/app/api/upload/route.ts new file mode 100644 index 0000000..342cb13 --- /dev/null +++ b/apps/web/app/api/upload/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const UPLOAD_API_URL = process.env.WORKER_API_URL || 'http://localhost:3001'; + +// Allowed file types and their corresponding MIME types +const ALLOWED_FILE_TYPES = { + 'application/pdf': 'pdf', + 'text/markdown': 'markdown', + 'text/plain': 'markdown', +}; + +// Maximum file size: 50MB +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +const uploadSchema = z.object({ + userId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + try { + // Parse form data + const formData = await request.formData(); + const file = formData.get('file') as File; + const userId = formData.get('userId') as string; + + // Validate required fields + if (!file) { + return NextResponse.json( + { success: false, error: 'No file provided' }, + { status: 400 } + ); + } + + if (!userId) { + return NextResponse.json( + { success: false, error: 'User ID is required' }, + { status: 400 } + ); + } + + // Validate user ID + const validatedData = uploadSchema.parse({ userId }); + + // Validate file type + const fileType = ALLOWED_FILE_TYPES[file.type as keyof typeof ALLOWED_FILE_TYPES]; + if (!fileType) { + return NextResponse.json( + { + success: false, + error: `Unsupported file type: ${file.type}. Only PDF and Markdown files are supported.` + }, + { status: 400 } + ); + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { + success: false, + error: `File size (${file.size} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)` + }, + { status: 400 } + ); + } + + // Validate file size (not empty) + if (file.size === 0) { + return NextResponse.json( + { success: false, error: 'File is empty' }, + { status: 400 } + ); + } + + // Convert file to buffer + const buffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + + // Convert to base64 + const base64Content = Buffer.from(uint8Array).toString('base64'); + + // Call worker service API + const response = await fetch(`${UPLOAD_API_URL}/api/jobs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: fileType, + filename: file.name, + content: base64Content, + userId: validatedData.userId, + metadata: { + originalName: file.name, + mimeType: file.type, + size: file.size, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + console.error('Worker service error:', errorData); + + return NextResponse.json( + { + success: false, + error: errorData.error || 'Failed to process file' + }, + { status: response.status } + ); + } + + const result = await response.json(); + + console.log(`File uploaded successfully: ${file.name}, Job ID: ${result.jobId}`); + + return NextResponse.json({ + success: true, + jobId: result.jobId, + status: result.status, + estimatedProcessingTime: result.estimatedProcessingTime, + fileInfo: result.fileInfo, + message: 'File uploaded and queued for processing successfully', + }); + + } catch (error) { + console.error('Upload error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: error.errors, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/user/settings/route.ts b/apps/web/app/api/user/settings/route.ts new file mode 100644 index 0000000..610a382 --- /dev/null +++ b/apps/web/app/api/user/settings/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/db"; +import { user } from "@/db/schema/auth"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +const updateSettingsSchema = z.object({ + chatModel: z.string().optional(), + summarizationModel: z.string().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const [userData] = await db + .select({ + chatModel: user.chatModel, + summarizationModel: user.summarizationModel, + }) + .from(user) + .where(eq(user.id, session.user.id)); + + if (!userData) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + return NextResponse.json(userData); + } catch (error) { + console.error("Error fetching user settings:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + try { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const body = await request.json(); + const validationResult = updateSettingsSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { error: "Invalid request body", details: validationResult.error }, + { status: 400 } + ); + } + + const updateData: Record = {}; + if (validationResult.data.chatModel) { + updateData.chatModel = validationResult.data.chatModel; + } + if (validationResult.data.summarizationModel) { + updateData.summarizationModel = validationResult.data.summarizationModel; + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + const [updatedUser] = await db + .update(user) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where(eq(user.id, session.user.id)) + .returning({ + chatModel: user.chatModel, + summarizationModel: user.summarizationModel, + }); + + if (!updatedUser) { + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } + + return NextResponse.json(updatedUser); + } catch (error) { + console.error("Error updating user settings:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/chat/chat-header.tsx b/apps/web/app/chat/chat-header.tsx new file mode 100644 index 0000000..1a6d207 --- /dev/null +++ b/apps/web/app/chat/chat-header.tsx @@ -0,0 +1,55 @@ +"use client" + +import { IconRobot } from "@tabler/icons-react" +import { ChevronLeft } from "lucide-react" + +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" +import { Button } from "@/components/ui/button" + +import { useChatModel } from "./chat-model-context" + +interface ChatHeaderProps { + sidebarOpen?: boolean + onToggleSidebar?: (open: boolean) => void +} + +export function ChatHeader({ sidebarOpen = true, onToggleSidebar }: ChatHeaderProps) { + const { selectedModelConfig } = useChatModel() + + return ( +
+
+ + +
+ + + +
+

Chat

+ {selectedModelConfig?.name && ( +

+ Currently using {selectedModelConfig.name} +

+ )} +
+
+ {!sidebarOpen && onToggleSidebar && ( + + )} +
+
+ ) +} diff --git a/apps/web/app/chat/chat-layout-client.tsx b/apps/web/app/chat/chat-layout-client.tsx new file mode 100644 index 0000000..932baaa --- /dev/null +++ b/apps/web/app/chat/chat-layout-client.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useState } from "react" +import { ChatHeader } from "./chat-header" +import { ChatModelProvider } from "./chat-model-context" +import { ChatPersistenceProvider } from "./chat-persistence-context" +import { ChatSearchProvider } from "./chat-search-context" +import { SessionHistorySidebar } from "@/components/session-history-sidebar" +import { useChatModel } from "./chat-model-context" + +function ChatLayoutContent({ + children, +}: { + children: React.ReactNode +}) { + const [sidebarOpen, setSidebarOpen] = useState(true) + const { sessionId, setSessionId } = useChatModel() + + return ( +
+
+ +
{children}
+
+
+ +
+
+ ) +} + +export function ChatLayoutClient({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +} diff --git a/apps/web/app/chat/chat-model-context.tsx b/apps/web/app/chat/chat-model-context.tsx new file mode 100644 index 0000000..ea89420 --- /dev/null +++ b/apps/web/app/chat/chat-model-context.tsx @@ -0,0 +1,54 @@ +"use client" + +import { createContext, useContext, useMemo } from "react" + +import { AVAILABLE_MODELS, type ModelConfig } from "@/components/model-selector" +import { useSessionHistory } from "@/hooks/use-session-history" +import { useState } from "react" + +interface ChatModelContextValue { + selectedModel: string + setSelectedModel: (modelId: string) => void + selectedModelConfig?: ModelConfig + sessionId: string | null + setSessionId: (sessionId: string | null) => void +} + +const ChatModelContext = createContext( + undefined +) + +export function ChatModelProvider({ + children, +}: { + children: React.ReactNode +}) { + const [selectedModel, setSelectedModel] = useState("gpt-4o-mini") + const { sessionId, setSessionId } = useSessionHistory() + + const selectedModelConfig = useMemo( + () => AVAILABLE_MODELS.find((model) => model.id === selectedModel), + [selectedModel] + ) + + const value = useMemo( + () => ({ selectedModel, setSelectedModel, selectedModelConfig, sessionId, setSessionId }), + [selectedModel, selectedModelConfig, sessionId, setSessionId] + ) + + return ( + + {children} + + ) +} + +export function useChatModel() { + const context = useContext(ChatModelContext) + + if (!context) { + throw new Error("useChatModel must be used within a ChatModelProvider") + } + + return context +} diff --git a/apps/web/app/chat/chat-persistence-context.tsx b/apps/web/app/chat/chat-persistence-context.tsx new file mode 100644 index 0000000..c8a18b3 --- /dev/null +++ b/apps/web/app/chat/chat-persistence-context.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { createContext, useContext, useState, useCallback } from "react"; + +interface ChatPersistenceContextValue { + currentSessionId: string | null; + setCurrentSessionId: (sessionId: string | null) => void; + selectedApiKeyId: string | null; + setSelectedApiKeyId: (apiKeyId: string | null) => void; +} + +const ChatPersistenceContext = createContext( + undefined +); + +export function ChatPersistenceProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [currentSessionId, setCurrentSessionId] = useState(null); + const [selectedApiKeyId, setSelectedApiKeyId] = useState(null); + + const value = { + currentSessionId, + setCurrentSessionId, + selectedApiKeyId, + setSelectedApiKeyId, + }; + + return ( + + {children} + + ); +} + +export function useChatPersistence() { + const context = useContext(ChatPersistenceContext); + + if (!context) { + throw new Error("useChatPersistence must be used within a ChatPersistenceProvider"); + } + + return context; +} \ No newline at end of file diff --git a/apps/web/app/chat/chat-runtime-provider.tsx b/apps/web/app/chat/chat-runtime-provider.tsx new file mode 100644 index 0000000..a228c2b --- /dev/null +++ b/apps/web/app/chat/chat-runtime-provider.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { ThreadMessageLike, AppendMessage } from "@assistant-ui/react"; +import { + AssistantRuntimeProvider, + useExternalStoreRuntime, +} from "@assistant-ui/react"; +import { useState, useEffect, useCallback } from "react"; +import { useChatModel } from "./chat-model-context"; +import { useChatSearch } from "./chat-search-context"; +import { cacheMessageSources, clearSourcesCache } from "./use-message-sources"; + +const convertMessage = (message: ThreadMessageLike) => { + return message; +}; + +interface ChatMessage { + id: string; + role: "user" | "assistant" | "system"; + content: string; + createdAt: string; + sources?: Array<{ + id: string; + url: string; + title: string; + faviconUrl?: string | null; + snippet?: string | null; + }>; +} + +// Extend ThreadMessageLike to include messageId for tracking +interface ExtendedThreadMessage extends ThreadMessageLike { + messageId?: string; +} + +export function ChatRuntimeProvider({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { selectedModel, sessionId, setSessionId } = useChatModel(); + const { useSearch } = useChatSearch(); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadedSessionId, setLoadedSessionId] = useState(null); + + // Load messages when sessionId changes (but only if it's different from what we already loaded) + useEffect(() => { + if (!sessionId) { + console.log("Starting new chat, clearing messages"); + setMessages([]); + setLoadedSessionId(null); + clearSourcesCache(); + return; + } + + // Don't reload if we already have this session loaded + if (sessionId === loadedSessionId) { + console.log("Session already loaded, skipping:", sessionId); + return; + } + + console.log("Loading session:", sessionId); + + const loadMessages = async () => { + setIsLoading(true); + try { + const response = await fetch(`/api/chat/messages?sessionId=${sessionId}`); + if (response.ok) { + const data = await response.json(); + const loadedMessages: ExtendedThreadMessage[] = data.messages.map( + (msg: ChatMessage) => { + // Cache sources for this message if available + if (msg.sources && msg.sources.length > 0) { + cacheMessageSources(msg.id, msg.sources); + } + + return { + role: msg.role, + content: [{ type: "text" as const, text: msg.content }], + messageId: msg.id, + }; + } + ); + setMessages(loadedMessages); + setLoadedSessionId(sessionId); + console.log("Loaded messages from database:", loadedMessages.length); + } + } catch (error) { + console.error("Failed to load messages:", error); + } finally { + setIsLoading(false); + } + }; + + loadMessages(); + }, [sessionId, loadedSessionId]); + + const onNew = useCallback( + async (message: AppendMessage) => { + if (message.content.length !== 1 || message.content[0]?.type !== "text") { + throw new Error("Only text content is supported"); + } + + const userMessage: ThreadMessageLike = { + role: "user", + content: [{ type: "text", text: message.content[0].text }], + }; + + console.log("Adding user message:", userMessage); + + // Add user message and get the updated messages array + let updatedMessages: ThreadMessageLike[] = []; + setMessages((currentMessages) => { + updatedMessages = [...currentMessages, userMessage]; + console.log("Updated messages count:", updatedMessages.length); + return updatedMessages; + }); + + try { + // Send message to API using the updated messages + console.log("Sending to API with", updatedMessages.length, "messages", useSearch ? "(with search)" : ""); + const response = await fetch("/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messages: updatedMessages, + sessionId: sessionId, + aiModel: selectedModel, + useSearch: useSearch, + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + // Capture session ID from response headers + const newSessionId = response.headers.get("X-Session-ID"); + if (newSessionId && newSessionId !== sessionId) { + console.log("Captured new session ID:", newSessionId); + setSessionId(newSessionId); + // Mark this session as already loaded to prevent reloading from database + setLoadedSessionId(newSessionId); + } + + // Read the streaming response + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let assistantText = ""; + let buffer = ""; + + if (reader) { + // Add initial empty assistant message + setMessages((currentMessages) => [ + ...currentMessages, + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ]); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + + // Keep the last incomplete line in the buffer + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + + console.log("Stream line:", line); + + // Handle "data: " prefixed lines from AI SDK stream + if (line.startsWith("data: ")) { + const jsonStr = line.substring(6); // Remove "data: " prefix + + // Skip [DONE] message + if (jsonStr === "[DONE]") { + console.log("Stream finished"); + continue; + } + + try { + const data = JSON.parse(jsonStr); + console.log("Parsed stream data:", data); + + // Handle text-delta events with "delta" field + if (data.type === "text-delta" && data.delta) { + assistantText += data.delta; + console.log("Updated assistant text length:", assistantText.length); + + // Update the last assistant message + setMessages((currentMessages) => [ + ...currentMessages.slice(0, -1), + { + role: "assistant", + content: [{ type: "text", text: assistantText }], + }, + ]); + } + } catch (e) { + console.error("Parse error:", e, "for line:", jsonStr); + } + } + } + } + + console.log("Streaming complete. Final text:", assistantText); + + // Fetch the latest message from the database to get its messageId and sources + const currentSessionId = newSessionId || sessionId; + if (currentSessionId) { + try { + console.log("Fetching latest messages to attach messageId and sources"); + const messagesResponse = await fetch(`/api/chat/messages?sessionId=${currentSessionId}`); + if (messagesResponse.ok) { + const data = await messagesResponse.json(); + const dbMessages = data.messages as ChatMessage[]; + + // Find the last assistant message in the database + const lastAssistantMessage = [...dbMessages] + .reverse() + .find((m: ChatMessage) => m.role === "assistant"); + + if (lastAssistantMessage) { + console.log("Found last assistant message:", lastAssistantMessage.id); + + // Cache sources if available + if (lastAssistantMessage.sources && lastAssistantMessage.sources.length > 0) { + console.log("Caching sources:", lastAssistantMessage.sources.length); + cacheMessageSources(lastAssistantMessage.id, lastAssistantMessage.sources); + } + + // Update the last assistant message in local state with the messageId + setMessages((currentMessages) => { + // Find the last assistant message in the local state + let lastAssistantIndex = -1; + for (let i = currentMessages.length - 1; i >= 0; i--) { + if (currentMessages[i].role === "assistant") { + lastAssistantIndex = i; + break; + } + } + + if (lastAssistantIndex === -1) { + console.warn("Could not find last assistant message in local state"); + return currentMessages; + } + + // Create a new array with the updated message + const updatedMessages = [...currentMessages]; + updatedMessages[lastAssistantIndex] = { + ...updatedMessages[lastAssistantIndex], + messageId: lastAssistantMessage.id, + } as ExtendedThreadMessage; + + console.log("Attached messageId to last assistant message:", lastAssistantMessage.id); + return updatedMessages; + }); + } + } + } catch (error) { + console.error("Failed to fetch latest messages for messageId:", error); + } + } + } + } catch (error) { + console.error("Chat API error:", error); + // Add error message + const errorMessage: ThreadMessageLike = { + role: "assistant", + content: [ + { + type: "text", + text: "Sorry, there was an error processing your request.", + }, + ], + }; + setMessages((currentMessages) => [...currentMessages, errorMessage]); + } + }, + [sessionId, selectedModel, setSessionId, useSearch] + ); + + const runtime = useExternalStoreRuntime({ + messages, + setMessages, + onNew, + convertMessage, + isRunning: isLoading, + }); + + // Debug: Log messages changes + useEffect(() => { + console.log("Messages state updated:", messages.length, "messages"); + messages.forEach((msg, idx) => { + console.log(` [${idx}] ${msg.role}:`, msg.content[0]); + }); + }, [messages]); + + return ( + + {children} + + ); +} diff --git a/apps/web/app/chat/chat-search-context.tsx b/apps/web/app/chat/chat-search-context.tsx new file mode 100644 index 0000000..ceb4761 --- /dev/null +++ b/apps/web/app/chat/chat-search-context.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { createContext, useContext, useState, ReactNode } from "react"; + +interface ChatSearchContextType { + useSearch: boolean; + setUseSearch: (useSearch: boolean) => void; +} + +const ChatSearchContext = createContext( + undefined +); + +export function ChatSearchProvider({ children }: { children: ReactNode }) { + const [useSearch, setUseSearch] = useState(false); + + return ( + + {children} + + ); +} + +export function useChatSearch() { + const context = useContext(ChatSearchContext); + if (context === undefined) { + throw new Error("useChatSearch must be used within a ChatSearchProvider"); + } + return context; +} diff --git a/apps/web/app/chat/layout.tsx b/apps/web/app/chat/layout.tsx new file mode 100644 index 0000000..6452a22 --- /dev/null +++ b/apps/web/app/chat/layout.tsx @@ -0,0 +1,37 @@ +import { cookies } from "next/headers" + +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" +import { AppSidebar } from "@/components/app-sidebar" + +import { ChatLayoutClient } from "./chat-layout-client" + +import "@/app/dashboard/theme.css" + +export default async function ChatLayout({ + children, +}: { + children: React.ReactNode +}) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get("sidebar_state")?.value === "true" + + return ( + + + + {children} + + + ) +} diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx new file mode 100644 index 0000000..3efaf06 --- /dev/null +++ b/apps/web/app/chat/page.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useSession } from "@/lib/auth-client" +import { useRouter } from "next/navigation" +import { IconLoader2 } from "@tabler/icons-react" +import { useEffect } from "react" +import { Thread } from "@/components/assistant-ui/thread" +import { Card } from "@/components/ui/card" +import { ChatRuntimeProvider } from "./chat-runtime-provider" + +export default function ChatPage() { + const { data: session, isPending } = useSession() + const router = useRouter() + + // Redirect if not authenticated + useEffect(() => { + if (!isPending && !session?.user) { + router.replace('/sign-in') + } + }, [router, session, isPending]) + + // Show loading state while checking authentication + if (isPending) { + return ( +
+ +
+ ) + } + + // Don't render if not authenticated (redirect will happen) + if (!session?.user) { + return null + } + + return ( +
+
+ + +
+ +
+
+
+
+
+ ) +} diff --git a/apps/web/app/chat/use-message-sources.tsx b/apps/web/app/chat/use-message-sources.tsx new file mode 100644 index 0000000..4bae649 --- /dev/null +++ b/apps/web/app/chat/use-message-sources.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export interface MessageSource { + id: string; + url: string; + title: string; + faviconUrl?: string | null; + snippet?: string | null; +} + +// In-memory cache for message sources +const sourcesCache = new Map(); + +export function useMessageSources(messageId?: string) { + const [sources, setSources] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!messageId) { + setSources([]); + return; + } + + // Check cache first + const cached = sourcesCache.get(messageId); + if (cached) { + setSources(cached); + return; + } + + // Fetch from API + const fetchSources = async () => { + setIsLoading(true); + try { + const response = await fetch(`/api/chat/sources?messageId=${messageId}`); + if (response.ok) { + const data = await response.json(); + const fetchedSources = data.sources || []; + sourcesCache.set(messageId, fetchedSources); + setSources(fetchedSources); + } + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setIsLoading(false); + } + }; + + fetchSources(); + }, [messageId]); + + return { sources, isLoading }; +} + +// Function to pre-populate cache with sources from message load +export function cacheMessageSources(messageId: string, sources: MessageSource[]) { + sourcesCache.set(messageId, sources); +} + +// Function to clear the cache (useful when switching sessions) +export function clearSourcesCache() { + sourcesCache.clear(); +} diff --git a/apps/web/app/dashboard/data.json b/apps/web/app/dashboard/data.json new file mode 100644 index 0000000..ec08736 --- /dev/null +++ b/apps/web/app/dashboard/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..59c1b81 --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,36 @@ +import { cookies } from "next/headers" + +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" +import { AppSidebar } from "@/components/app-sidebar" +import { SiteHeader } from "@/components/site-header" + +import "@/app/dashboard/theme.css" + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get("sidebar_state")?.value === "true" + + return ( + + + + +
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/app/dashboard/loading.tsx b/apps/web/app/dashboard/loading.tsx new file mode 100644 index 0000000..613cf19 --- /dev/null +++ b/apps/web/app/dashboard/loading.tsx @@ -0,0 +1,65 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +export default function DashboardLoading() { + return ( +
+
+ {/* Section Cards Skeleton */} +
+ {[...Array(4)].map((_, i) => ( + + + + + + + ))} +
+ + {/* API Key Manager Skeleton */} +
+ + + + + + + + + +
+ + {/* Chart Skeleton */} +
+ + + + + + + + + +
+ + {/* Table Skeleton */} +
+ + + + + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..4f4721f --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -0,0 +1,55 @@ +import { ChartAreaInteractive } from "@//components/chart-area-interactive" +import { SectionCards } from "@//components/section-cards" +import { ApiKeyManager } from "@/components/api-key-manager" +import { AnalyticsDataTable } from "@/components/analytics-data-table" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import type { AnalyticsData } from "@/types/analytics" + +async function getAnalyticsData(): Promise { + try { + const headersList = await headers() + const protocol = headersList.get('x-forwarded-proto') || 'http' + const host = headersList.get('host') || 'localhost:3000' + const baseUrl = `${protocol}://${host}` + + const response = await fetch(`${baseUrl}/api/dashboard/analytics`, { + headers: Object.fromEntries(headersList.entries()), + cache: 'no-store', + }) + + if (!response.ok) { + console.error('Failed to fetch analytics data:', response.statusText) + return null + } + + return response.json() + } catch (error) { + console.error('Error fetching analytics data:', error) + return null + } +} + +export default async function Page() { + const session = await auth.api.getSession({ + headers: await headers() + }) + + // Fetch analytics data + const analyticsData = await getAnalyticsData() + + return ( +
+
+ +
+ +
+
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/app/dashboard/theme.css b/apps/web/app/dashboard/theme.css new file mode 100644 index 0000000..ebb77b4 --- /dev/null +++ b/apps/web/app/dashboard/theme.css @@ -0,0 +1,105 @@ +body { + @apply overscroll-none bg-transparent; + } + + :root { + --font-sans: var(--font-inter); + --header-height: calc(var(--spacing) * 12 + 1px); + } + + .theme-scaled { + @media (min-width: 1024px) { + --radius: 0.6rem; + --text-lg: 1.05rem; + --text-base: 0.85rem; + --text-sm: 0.8rem; + --spacing: 0.222222rem; + } + + [data-slot="card"] { + --spacing: 0.16rem; + } + + [data-slot="select-trigger"], + [data-slot="toggle-group-item"] { + --spacing: 0.222222rem; + } + } + + .theme-default, + .theme-default-scaled { + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } + } + + .theme-blue, + .theme-blue-scaled { + --primary: var(--color-blue-600); + --primary-foreground: var(--color-blue-50); + + @variant dark { + --primary: var(--color-blue-500); + --primary-foreground: var(--color-blue-50); + } + } + + .theme-green, + .theme-green-scaled { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + + @variant dark { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + } + } + + .theme-amber, + .theme-amber-scaled { + --primary: var(--color-amber-600); + --primary-foreground: var(--color-amber-50); + + @variant dark { + --primary: var(--color-amber-500); + --primary-foreground: var(--color-amber-50); + } + } + + .theme-mono, + .theme-mono-scaled { + --font-sans: var(--font-mono); + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } + + .rounded-xs, + .rounded-sm, + .rounded-md, + .rounded-lg, + .rounded-xl { + @apply !rounded-none; + border-radius: 0; + } + + .shadow-xs, + .shadow-sm, + .shadow-md, + .shadow-lg, + .shadow-xl { + @apply !shadow-none; + } + + [data-slot="toggle-group"], + [data-slot="toggle-group-item"] { + @apply !rounded-none !shadow-none; + } + } \ No newline at end of file diff --git a/apps/web/app/documents/layout.tsx b/apps/web/app/documents/layout.tsx new file mode 100644 index 0000000..73b40c1 --- /dev/null +++ b/apps/web/app/documents/layout.tsx @@ -0,0 +1,35 @@ +import { cookies } from "next/headers" + +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" +import { AppSidebar } from "@/components/app-sidebar" + +import "@/app/dashboard/theme.css" + +export default async function DocumentsLayout({ + children, +}: { + children: React.ReactNode +}) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get("sidebar_state")?.value === "true" + + return ( + + + + {children} + + + ) +} \ No newline at end of file diff --git a/apps/web/app/documents/page.tsx b/apps/web/app/documents/page.tsx new file mode 100644 index 0000000..300d8a8 --- /dev/null +++ b/apps/web/app/documents/page.tsx @@ -0,0 +1,113 @@ +"use client" + +import { useSession } from "@/lib/auth-client" +import { useRouter } from "next/navigation" +import { IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { FileUpload } from "@/components/file-upload" +import { DocumentSearch } from "@/components/document-search" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Upload, Search, FileText } from "lucide-react" + +export default function DocumentsPage() { + const { data: session, isPending } = useSession() + const router = useRouter() + const [activeTab, setActiveTab] = useState("upload") + + // Redirect if not authenticated + useEffect(() => { + if (!isPending && !session?.user) { + router.replace('/sign-in') + } + }, [router, session, isPending]) + + // Show loading state while checking authentication + if (isPending) { + return ( +
+ +
+ ) + } + + // Don't render if not authenticated (redirect will happen) + if (!session?.user) { + return null + } + + const userId = session.user.id || session.user.email || 'anonymous' + + return ( +
+
+
+

Document Management

+

+ Upload, search, and manage your documents with AI-powered analysis +

+
+ + + + + + Upload Documents + + + + Search & Manage + + + + +
+ { + // Switch to search tab when upload is complete + setActiveTab("search") + }} + /> + + + + + + How It Works + + + +
+
+

1. Upload Files

+

+ Upload PDF or Markdown files. We support files up to 50MB in size. +

+
+
+

2. AI Processing

+

+ Documents are automatically processed using AI to extract and understand content. +

+
+
+

3. Search & Discover

+

+ Search through your documents using natural language queries. +

+
+
+
+
+
+
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..c68978a --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,166 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --font-parkinsans: var(--font-parkinsans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); + /* Additional color variables for aurora background utility */ + --blue-500: #3b82f6; + --indigo-300: #a5b4fc; + --blue-300: #93c5fd; + --violet-200: #ddd6fe; + --blue-400: #60a5fa; + --white: #ffffff; + --black: #000000; + --transparent: transparent; +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Utilities for Aurora background animation */ +@keyframes aurora { + from { + background-position: 50% 50%, 50% 50%; + } + to { + background-position: 350% 50%, 350% 50%; + } +} + +@utility animate-aurora { + animation: aurora 60s linear infinite; +} + +/* Custom scrollbar styling for source cards */ +@layer utilities { + .scrollbar-thin::-webkit-scrollbar { + height: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 3px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } +} diff --git a/apps/web/app/image/layout.tsx b/apps/web/app/image/layout.tsx new file mode 100644 index 0000000..9254b6b --- /dev/null +++ b/apps/web/app/image/layout.tsx @@ -0,0 +1,34 @@ +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; + +import "@/app/dashboard/theme.css"; + +export const metadata = { + title: "AI Image Generation", + description: "Generate stunning images using AI", +}; + +export default function ImageLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + ); +} diff --git a/apps/web/app/image/page.tsx b/apps/web/app/image/page.tsx new file mode 100644 index 0000000..c247809 --- /dev/null +++ b/apps/web/app/image/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function LegacyImagePage() { + redirect("/images"); +} diff --git a/apps/web/app/images/[sessionId]/page.tsx b/apps/web/app/images/[sessionId]/page.tsx new file mode 100644 index 0000000..4858c26 --- /dev/null +++ b/apps/web/app/images/[sessionId]/page.tsx @@ -0,0 +1,451 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useMemo, useState } from "react"; + +import { useSession } from "@/lib/auth-client"; +import { useRouter } from "next/navigation"; +import { formatDistanceToNow } from "date-fns"; +import { + IconArrowUp, + IconArrowUpBar, + IconArrowUpCircle, + IconBrandAppgallery, + IconCheck, + IconClock, + IconLoader2, + IconPhoto, + IconRefresh, + IconReservedLine, + IconRotateClockwise, +} from "@tabler/icons-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; + +interface GenerationAsset { + id: string; + role: "input" | "mask" | "output"; + url: string; + mimeType: string | null; + createdAt: string; +} + +interface GenerationRecord { + id: string; + status: "queued" | "running" | "succeeded" | "failed" | "canceled"; + provider: string; + model: string; + prompt: string | null; + negativePrompt: string | null; + params: Record | null; + createdAt: string; + completedAt: string | null; + durationMs: number | null; + parentGenerationId: string | null; + sourceAssetId: string | null; + maskAssetId: string | null; + assets: GenerationAsset[]; +} + +interface SessionRecord { + id: string; + title: string | null; + provider: string | null; + model: string | null; + updatedAt: string; + createdAt: string; +} + +interface SessionResponse { + session: SessionRecord; + generations: GenerationRecord[]; +} + +export default function ImageSessionPage({ + params, +}: { + params: { sessionId: string }; +}) { + const { data: session, isPending } = useSession(); + const router = useRouter(); + + const [prompt, setPrompt] = useState(""); + const [sessionData, setSessionData] = useState(null); + const [selectedGenerationId, setSelectedGenerationId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isPending) { + if (!session?.user) { + router.replace("/sign-in"); + } else { + void loadSession(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.user, isPending, params.sessionId]); + + const loadSession = async () => { + try { + setIsLoading(true); + const response = await fetch(`/api/images/sessions/${params.sessionId}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("Session not found"); + } + throw new Error("Failed to load session"); + } + + const data = (await response.json()) as SessionResponse; + setSessionData(data); + setError(null); + + const latest = [...data.generations].reverse().find((gen) => + gen.status === "succeeded" + ) || data.generations[data.generations.length - 1]; + + if (latest) { + setSelectedGenerationId(latest.id); + } + } catch (error) { + console.error(error); + const message = + error instanceof Error ? error.message : "Failed to load session"; + setError(message); + toast.error(message); + } finally { + setIsLoading(false); + } + }; + + const selectedGeneration = useMemo(() => { + if (!sessionData?.generations?.length) return null; + if (!selectedGenerationId) { + return sessionData.generations[sessionData.generations.length - 1]; + } + return sessionData.generations.find((gen) => gen.id === selectedGenerationId) ?? null; + }, [sessionData, selectedGenerationId]); + + const selectedOutputAsset = useMemo(() => { + return selectedGeneration?.assets.find((asset) => asset.role === "output") ?? null; + }, [selectedGeneration]); + + const selectedStepNumber = useMemo(() => { + if (!sessionData?.generations?.length || !selectedGeneration) return null; + const idx = getGenerationIndex(sessionData.generations, selectedGeneration.id); + return idx >= 0 ? idx + 1 : sessionData.generations.length; + }, [sessionData, selectedGeneration]); + + const handleGenerate = async (event: React.FormEvent) => { + event.preventDefault(); + if (!prompt.trim()) { + toast.error("Enter a prompt to iterate"); + return; + } + + try { + setIsGenerating(true); + const response = await fetch("/api/images/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sessionId: params.sessionId, + prompt: prompt.trim(), + parentGenerationId: selectedGeneration?.id, + sourceAssetId: selectedOutputAsset?.id, + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Generation failed"); + } + + const data = await response.json(); + const generation: GenerationRecord = { + ...data.generation, + assets: [ + { + ...data.outputAsset, + role: "output", + }, + ], + }; + + setSessionData((prev) => { + if (!prev) return prev; + return { + session: data.session ?? prev.session, + generations: [...prev.generations, generation], + }; + }); + + setPrompt(""); + setSelectedGenerationId(generation.id); + toast.success("New variation generated"); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : "Generation failed"); + } finally { + setIsGenerating(false); + } + }; + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!session?.user) { + return null; + } + + return ( +
+
+
+ + +
+

+ {sessionData?.session?.title || "Untitled session"} +

+ {sessionData?.session && ( +
+ + Updated {formatDistanceToNow(new Date(sessionData.session.updatedAt), { addSuffix: true })} + + + + {(sessionData.session.provider ?? "google").toUpperCase()} + + + {sessionData.session.model ?? "gemini-2.5-flash-image-preview"} +
+ )} +
+ + + + {isLoading ? ( + + ) : error ? ( +
+

{error}

+
+ ) : !sessionData ? ( +
+ Session not found. +
+ ) : ( + <> +
+
+ {selectedOutputAsset ? ( + {selectedGeneration?.prompt + ) : ( +
+ +
+ )} + {selectedGeneration && selectedStepNumber && ( +
+ + Step {selectedStepNumber} +
+ )} +
+ + {selectedGeneration && ( +
+
+
+ + {formatDistanceToNow(new Date(selectedGeneration.createdAt), { addSuffix: true })} +
+
+ + {selectedGeneration.status} + + {typeof selectedGeneration.durationMs === "number" && ( + + {(selectedGeneration.durationMs / 1000).toFixed(1)}s + + )} +
+
+ {selectedGeneration.prompt && ( +
+

{selectedGeneration.prompt}

+ {selectedGeneration.negativePrompt && ( +

+ Negative: {selectedGeneration.negativePrompt} +

+ )} +
+ )} +
+ )} + + +
+

Prompt

+

+ The new prompt will build from the selected image. Use the history below to pick a different base. +

+
+
+