Repository: miurla/morphic Branch: main Commit: 42f5d8029c12 Files: 295 Total size: 1012.7 KB Directory structure: gitextract_mowel1np/ ├── .eslintrc.json ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── ci.yml │ ├── docker-build.yml │ └── release.yml ├── .gitignore ├── .mcp.json ├── .prettierignore ├── .vscode/ │ └── settings.json ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── app/ │ ├── api/ │ │ ├── advanced-search/ │ │ │ └── route.ts │ │ ├── chat/ │ │ │ └── route.ts │ │ ├── chats/ │ │ │ └── route.ts │ │ ├── feedback/ │ │ │ ├── __tests__/ │ │ │ │ └── route.test.ts │ │ │ └── route.ts │ │ └── upload/ │ │ └── route.ts │ ├── auth/ │ │ ├── confirm/ │ │ │ └── route.ts │ │ ├── error/ │ │ │ └── page.tsx │ │ ├── forgot-password/ │ │ │ └── page.tsx │ │ ├── login/ │ │ │ └── page.tsx │ │ ├── oauth/ │ │ │ └── route.ts │ │ ├── sign-up/ │ │ │ └── page.tsx │ │ ├── sign-up-success/ │ │ │ └── page.tsx │ │ └── update-password/ │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── search/ │ ├── [id]/ │ │ └── page.tsx │ ├── loading.tsx │ └── page.tsx ├── components/ │ ├── __tests__/ │ │ └── research-process-section.test.tsx │ ├── action-buttons.tsx │ ├── answer-section.tsx │ ├── app-sidebar.tsx │ ├── artifact/ │ │ ├── artifact-content.tsx │ │ ├── artifact-context.tsx │ │ ├── artifact-root.tsx │ │ ├── chat-artifact-container.tsx │ │ ├── reasoning-content.tsx │ │ ├── search-artifact-content.tsx │ │ ├── todo-invocation-content.tsx │ │ └── tool-invocation-content.tsx │ ├── attachment-preview.tsx │ ├── auth-modal.tsx │ ├── chat-error.tsx │ ├── chat-messages.tsx │ ├── chat-panel.tsx │ ├── chat-share.tsx │ ├── chat.tsx │ ├── citation-context.tsx │ ├── citation-link.tsx │ ├── collapsible-message.tsx │ ├── current-user-avatar.tsx │ ├── custom-link.tsx │ ├── data-section.tsx │ ├── default-skeleton.tsx │ ├── drag-overlay.tsx │ ├── dynamic-tool-display.tsx │ ├── error-modal.tsx │ ├── external-link-items.tsx │ ├── feedback-modal.tsx │ ├── fetch-section.tsx │ ├── file-upload-button.tsx │ ├── forgot-password-form.tsx │ ├── guest-menu.tsx │ ├── header.tsx │ ├── inspector/ │ │ ├── inspector-drawer.tsx │ │ └── inspector-panel.tsx │ ├── login-form.tsx │ ├── message-actions.tsx │ ├── message.tsx │ ├── model-type-selector.tsx │ ├── process-header.tsx │ ├── process-rail.tsx │ ├── question-confirmation.tsx │ ├── reasoning-section.tsx │ ├── related-questions.tsx │ ├── render-message.tsx │ ├── research-process-section.tsx │ ├── retry-button.tsx │ ├── search-mode-selector.tsx │ ├── search-results-image.tsx │ ├── search-results.tsx │ ├── search-section.tsx │ ├── section.tsx │ ├── sidebar/ │ │ ├── chat-history-client.tsx │ │ ├── chat-history-section.tsx │ │ ├── chat-history-skeleton.tsx │ │ ├── chat-menu-item.tsx │ │ └── clear-history-action.tsx │ ├── sign-up-form.tsx │ ├── source-favicons.tsx │ ├── theme-menu-items.tsx │ ├── theme-provider.tsx │ ├── todo-list-content.tsx │ ├── tool-badge.tsx │ ├── tool-section.tsx │ ├── tool-todo-display.tsx │ ├── ui/ │ │ ├── alert-dialog.tsx │ │ ├── animated-logo.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── hover-card.tsx │ │ ├── icons.tsx │ │ ├── index.ts │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── password-input.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── spinner.tsx │ │ ├── status-indicator.tsx │ │ ├── switch.tsx │ │ ├── textarea.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-button.tsx │ │ └── tooltip.tsx │ ├── update-password-form.tsx │ ├── uploaded-file-list.tsx │ ├── user-file-section.tsx │ ├── user-menu.tsx │ ├── user-text-section.tsx │ ├── video-carousel-dialog.tsx │ ├── video-result-grid.tsx │ └── video-search-results.tsx ├── components.json ├── config/ │ └── models/ │ ├── cloud.json │ └── default.json ├── docker-compose.yaml ├── docs/ │ ├── CONFIGURATION.md │ └── DOCKER.md ├── drizzle/ │ ├── 0000_black_lifeguard.sql │ ├── 0001_thin_supreme_intelligence.sql │ ├── 0002_material_crystal.sql │ ├── 0003_heavy_whirlwind.sql │ ├── 0004_natural_wallow.sql │ ├── 0005_awesome_riptide.sql │ ├── 0006_brainy_wrecking_crew.sql │ ├── 0007_illegal_mephistopheles.sql │ ├── 0008_glamorous_riptide.sql │ ├── 0009_thankful_may_parker.sql │ ├── 0010_lonely_kang.sql │ ├── meta/ │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ ├── 0003_snapshot.json │ │ ├── 0004_snapshot.json │ │ ├── 0005_snapshot.json │ │ ├── 0006_snapshot.json │ │ ├── 0007_snapshot.json │ │ ├── 0008_snapshot.json │ │ ├── 0009_snapshot.json │ │ ├── 0010_snapshot.json │ │ └── _journal.json │ ├── relations.ts │ └── schema.ts ├── drizzle.config.ts ├── hooks/ │ ├── use-auth-check.tsx │ ├── use-current-user-image.ts │ ├── use-current-user-name.ts │ ├── use-file-dropzone.ts │ └── use-mobile.tsx ├── instrumentation.ts ├── lib/ │ ├── actions/ │ │ ├── __tests__/ │ │ │ ├── chat.test.ts │ │ │ └── feedback.test.ts │ │ ├── chat.ts │ │ ├── feedback.ts │ │ └── site-feedback.ts │ ├── agents/ │ │ ├── generate-related-questions.ts │ │ ├── prompts/ │ │ │ ├── related-questions-prompt.ts │ │ │ └── search-mode-prompts.ts │ │ ├── researcher.ts │ │ └── title-generator.ts │ ├── analytics/ │ │ ├── index.ts │ │ ├── track-chat-event.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── auth/ │ │ └── get-current-user.ts │ ├── config/ │ │ ├── load-models-config.ts │ │ ├── model-types.ts │ │ ├── ollama-validator.ts │ │ └── search-modes.ts │ ├── constants/ │ │ └── index.ts │ ├── contexts/ │ │ └── user-context.tsx │ ├── db/ │ │ ├── __tests__/ │ │ │ ├── rls-policies.integration.test.ts │ │ │ └── with-rls.test.ts │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── migrate.ts │ │ ├── relations.ts │ │ ├── schema.ts │ │ └── with-rls.ts │ ├── firecrawl/ │ │ ├── client.ts │ │ ├── index.ts │ │ └── types.ts │ ├── hooks/ │ │ ├── use-copy-to-clipboard.ts │ │ └── use-media-query.ts │ ├── ollama/ │ │ ├── client.ts │ │ └── types.ts │ ├── rate-limit/ │ │ ├── __tests__/ │ │ │ └── guest-limit.test.ts │ │ ├── chat-limits.ts │ │ └── guest-limit.ts │ ├── schema/ │ │ ├── fetch.tsx │ │ ├── question.ts │ │ ├── related.tsx │ │ └── search.tsx │ ├── storage/ │ │ └── r2-client.ts │ ├── streaming/ │ │ ├── __tests__/ │ │ │ ├── create-ephemeral-chat-stream-response.test.ts │ │ │ └── prune-messages-integration.test.ts │ │ ├── create-chat-stream-response.ts │ │ ├── create-ephemeral-chat-stream-response.ts │ │ ├── helpers/ │ │ │ ├── __tests__/ │ │ │ │ └── prepare-messages.test.ts │ │ │ ├── persist-stream-results.ts │ │ │ ├── prepare-messages.ts │ │ │ ├── stream-related-questions.ts │ │ │ ├── strip-reasoning-parts.ts │ │ │ └── types.ts │ │ └── types.ts │ ├── supabase/ │ │ ├── client.ts │ │ ├── middleware.ts │ │ └── server.ts │ ├── tools/ │ │ ├── dynamic.ts │ │ ├── fetch.ts │ │ ├── question.ts │ │ ├── search/ │ │ │ └── providers/ │ │ │ ├── base.ts │ │ │ ├── brave.ts │ │ │ ├── exa.ts │ │ │ ├── firecrawl.ts │ │ │ ├── index.ts │ │ │ ├── searxng.ts │ │ │ └── tavily.ts │ │ ├── search.ts │ │ └── todo.ts │ ├── types/ │ │ ├── agent.ts │ │ ├── ai.ts │ │ ├── dynamic-tools.ts │ │ ├── index.ts │ │ ├── message-persistence.ts │ │ ├── model-type.ts │ │ ├── models.ts │ │ └── search.ts │ └── utils/ │ ├── __tests__/ │ │ ├── citation.test.ts │ │ ├── context-window.test.ts │ │ ├── domain.test.ts │ │ └── model-selection.test.ts │ ├── citation.ts │ ├── context-window.ts │ ├── cookies.ts │ ├── domain.ts │ ├── index.ts │ ├── message-mapping.ts │ ├── message-utils.ts │ ├── model-selection.ts │ ├── perf-logging.ts │ ├── perf-tracking.ts │ ├── registry.ts │ ├── retry.ts │ ├── search-config.ts │ ├── telemetry.ts │ └── url.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── prettier.config.js ├── proxy.ts ├── scripts/ │ ├── README.md │ ├── chat-cli.ts │ └── test-cache-performance.ts ├── searxng-limiter.toml ├── searxng-settings.yml ├── tsconfig.json ├── vitest.config.mts └── vitest.setup.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals", "plugins": ["simple-import-sort"], "rules": { "simple-import-sort/imports": [ "error", { "groups": [ // React and Next.js imports ["^react", "^next"], // Third party imports ["^@?\\w"], // Internal imports ["^@/types"], ["^@/config"], ["^@/lib"], ["^@/hooks"], ["^@/components/ui"], ["^@/components"], ["^@/registry"], ["^@/styles"], ["^@/app"], // Side effect imports ["^\\u0000"], // Parent imports ["^\\.\\.(?!/?$)", "^\\.\\./?$"], // Other relative imports ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], // Style imports ["^.+\\.s?css$"] ] } ], "simple-import-sort/exports": "error" } } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: miurla ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug description: File a bug/issue title: '[BUG] ' labels: ['Bug', 'Needs Triage'] body: - type: checkboxes attributes: label: Is there an existing issue for this? description: Please search to see if an issue already exists for the bug you encountered. options: - label: I have searched the existing issues required: true - type: checkboxes attributes: label: Vercel Runtime Logs description: If this is a Vercel environment issue, have you checked the Vercel Runtime Logs? (https://vercel.com/docs/observability/runtime-logs) options: - label: I have checked the Vercel Runtime Logs for errors (if applicable) required: false - type: textarea attributes: label: Current Behavior description: A concise description of what you're experiencing. validations: required: true - type: textarea attributes: label: Expected Behavior description: A concise description of what you expected to happen. validations: required: true - type: textarea attributes: label: Steps To Reproduce description: Steps to reproduce the behavior. placeholder: | 1. In this environment... 2. With this config... 3. Run '...' 4. See error... validations: required: true - type: textarea attributes: label: Environment description: | examples: - Browser: Chrome 52.0.2743.116 value: | - OS: - Browser: render: markdown validations: required: true - type: textarea attributes: label: Anything else? description: | Links? References? Anything that will give us more context about the issue you are encountering! Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: ✨ Feature Request description: Propose a new feature for Morphic. labels: [] body: - type: markdown attributes: value: | This template is to propose new features for Morphic. Please fill out the following information to help us understand your feature request. - type: textarea attributes: label: Feature Description description: A detailed description of the feature you are proposing for Morphic. Include any relevant technical details. placeholder: | Feature description... validations: required: true - type: textarea attributes: label: Use Case description: Provide a use case where this feature would be beneficial placeholder: | Use case... validations: required: true - type: textarea attributes: label: Additional context description: | Any extra information that might help us understand your feature request. placeholder: | Additional context... ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: lint: runs-on: ubuntu-latest name: Lint steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.2.12 - name: Install dependencies run: bun install - name: Run linting run: bun lint typecheck: runs-on: ubuntu-latest name: Type Check steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.2.12 - name: Install dependencies run: bun install - name: Run type checking run: bun typecheck format: runs-on: ubuntu-latest name: Format Check steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.2.12 - name: Install dependencies run: bun install - name: Check formatting run: bun format:check test: runs-on: ubuntu-latest name: Test steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.2.12 - name: Install dependencies run: bun install - name: Run tests run: | if [ -f "vitest.config.ts" ] || [ -f "vitest.config.mts" ]; then bun run test fi env: DATABASE_URL: postgresql://user:pass@localhost:5432/db NODE_ENV: test build: runs-on: ubuntu-latest name: Build needs: [lint, typecheck, format, test] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.2.12 - name: Install dependencies run: bun install - name: Build application run: bun run build env: DATABASE_URL: postgresql://user:pass@localhost:5432/db ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Docker Build and Push on: push: branches: [main] jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' # e.g. v1.0.0, v1.0.0-beta.5 concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: gate: name: Select target branch and validate runs-on: ubuntu-latest outputs: allowed: ${{ steps.check.outputs.allowed }} prerelease: ${{ steps.check.outputs.prerelease }} targetBranch: ${{ steps.check.outputs.targetBranch }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Validate tag and choose branch id: check shell: bash run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" # Validate semver-like tag: vMAJOR.MINOR.PATCH[-PRERELEASE] if [[ ! "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-.+)?$ ]]; then echo "::error::Invalid semver tag: $TAG"; exit 1 fi MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" PRERE="${BASH_REMATCH[4]:-}" BASE="v${MAJOR}.${MINOR}.${PATCH}" if [[ -n "$PRERE" ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" # Candidate release branches for pre-releases (highest priority first) CANDIDATES=( "origin/${BASE}" "origin/release/${BASE}" "origin/v${MAJOR}.${MINOR}" "origin/release/v${MAJOR}.${MINOR}" ) else echo "prerelease=false" >> "$GITHUB_OUTPUT" CANDIDATES=("origin/main") fi git fetch --no-tags origin +refs/heads/*:refs/remotes/origin/* TARGET="" for ref in "${CANDIDATES[@]}"; do if git rev-parse --verify -q "$ref" >/dev/null; then if git merge-base --is-ancestor "$GITHUB_SHA" "$ref"; then TARGET="$ref"; break fi fi done if [[ -z "$TARGET" ]]; then echo "allowed=false" >> "$GITHUB_OUTPUT" echo "::warning::Tag $TAG is not contained in any target branch candidate (${CANDIDATES[*]}). Skipping release." else echo "allowed=true" >> "$GITHUB_OUTPUT" echo "targetBranch=$TARGET" >> "$GITHUB_OUTPUT" echo "Selected target: $TARGET" fi release: name: Build and create GitHub Release needs: gate if: needs.gate.outputs.allowed == 'true' runs-on: ubuntu-latest permissions: contents: write packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 'latest' - name: Install deps run: bun install - name: Typecheck run: bun run typecheck - name: Lint run: bun run lint - name: Build run: bun run build env: DATABASE_URL: postgresql://user:pass@localhost:5432/db # Optional: attach build artifacts to the release # - name: Archive Next.js build output # run: tar -czf next-build.tar.gz .next - name: Create GitHub Release uses: ncipollo/release-action@v1 with: tag: ${{ github.ref_name }} generateReleaseNotes: true prerelease: ${{ needs.gate.outputs.prerelease }} # artifacts: next-build.tar.gz ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # temporary files .tmp/ ================================================ FILE: .mcp.json ================================================ { "mcpServers": { "next-devtools": { "type": "stdio", "command": "npx", "args": ["next-devtools-mcp@latest"], "env": {} } } } ================================================ FILE: .prettierignore ================================================ # Dependencies node_modules/ .next/ out/ build/ dist/ # Environment .env* # Generated files *.lock bun.lock package-lock.json yarn.lock pnpm-lock.yaml # IDE .vscode/ .idea/ # Misc *.log .DS_Store coverage/ .cache/ # Next.js .next/ out/ # Production build/ # Temporary files tmp/ temp/ # Database lib/db/migrations/ ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "cSpell.words": ["openai", "Tavily"], "editor.codeActionsOnSave": { "source.organizeImports": "always" } } ================================================ FILE: AGENTS.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Key Commands ### Development - `bun dev` - Start development server with Turbopack (http://localhost:3000) - `bun run build` - Create production build - `bun start` - Start production server - `bun lint` - Run ESLint for code quality checks and import sorting - `bun typecheck` - Run TypeScript type checking - `bun format` - Format code with Prettier - `bun format:check` - Check code formatting without modifying files - `bun migrate` - Run database migrations - `bun run test` - Run tests with Vitest (use this, NOT `bun test`) - `bun run test:watch` - Run tests in watch mode ### Docker - `docker compose up -d` - Run the application with Docker (includes PostgreSQL 17, Redis, Morphic app, and SearXNG) - `docker compose down` - Stop all containers - `docker compose down -v` - Stop all containers and remove volumes (deletes database data) - `docker pull ghcr.io/miurla/morphic:latest` - Pull prebuilt Docker image #### Docker Authentication **Default Behavior**: Docker deployments run in **anonymous mode** (authentication disabled). When running with Docker Compose, `ENABLE_AUTH=false` is set by default, allowing personal use without Supabase setup. All users share a single anonymous user ID. **⚠️ Security Warning:** - Anonymous mode is **only for personal, single-user local environments** - All chat history is shared under one user ID - **NOT suitable** for multi-user or production deployments - Morphic Cloud deployments block `ENABLE_AUTH=false` automatically **Enabling Authentication:** To require Supabase authentication, set: ```bash ENABLE_AUTH=true # or remove ENABLE_AUTH from docker-compose.yaml NEXT_PUBLIC_SUPABASE_URL=[your-supabase-url] NEXT_PUBLIC_SUPABASE_ANON_KEY=[your-supabase-anon-key] ``` **Implementation:** - Auth logic: [lib/auth/get-current-user.ts:22-40](lib/auth/get-current-user.ts#L22-L40) - Always warns when `ENABLE_AUTH=false` (except in tests) - Guards against `MORPHIC_CLOUD_DEPLOYMENT=true` ## Architecture Overview ### Tech Stack - **Next.js 16.0.0** with App Router, React Server Components, and Turbopack - **React 19.2.0** with TypeScript for type safety - **Vercel AI SDK 5.0.0-alpha.2** for AI streaming and GenerativeUI - **Supabase** for authentication and backend services - **PostgreSQL** with Drizzle ORM for database and chat history storage - **Redis** (Upstash or local) for SearXNG advanced search caching - **Tailwind CSS** with shadcn/ui components ### Core Architecture 1. **App Router Structure** (`/app`) - `/api/` - Backend API routes for chat, search, and auth endpoints - `/auth/` - Authentication pages (login, signup, password reset) - `/search/` - Search functionality and results display - `/share/` - Sharing functionality for search results 2. **AI Integration** (`/lib`) - `/lib/agents/` - AI agents for research and question generation - `/lib/config/` - Model configuration management - `/lib/streaming/` - Stream handling for AI responses - `/lib/tools/` - Search and retrieval tool implementations - Models configured in `public/config/models.json` 3. **Database** (`/lib/db`) - PostgreSQL database with Drizzle ORM - Schema defined in `/lib/db/schema.ts` - Migrations in `/lib/db/migrations/` - Database actions in `/lib/actions/chat-db.ts` 4. **Search System** - Multiple providers: Tavily (default), SearXNG (self-hosted), Exa (neural), Brave (optional) - Brave Search is optional; if API key is not provided, type="general" searches fall back to primary provider - Video/image search support depends on configured providers (Brave provides best multimedia support) - URL-specific search capabilities - Configurable search depth and result limits 5. **Component Organization** (`/components`) - `/artifact/` - Search result and AI response display components - `/sidebar/` - Chat history and navigation - `/ui/` - Reusable UI components from shadcn/ui - Feature-specific components (auth forms, chat interfaces) 6. **State Management** - Server-side state via React Server Components - Client-side hooks in `/hooks/` - Redis for persistent chat history - Supabase for user data ## Environment Configuration ### Required Variables ```bash OPENAI_API_KEY= # Default AI provider TAVILY_API_KEY= # Default search provider DATABASE_URL= # PostgreSQL connection string ``` ### Optional Features - Chat history: Set `ENABLE_SAVE_CHAT_HISTORY=true` and configure Redis - Alternative AI providers: Add corresponding API keys (ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, etc.) - Alternative search: Configure SEARCH_API and provider-specific settings - Sharing: Set `NEXT_PUBLIC_ENABLE_SHARE=true` ## Key Development Patterns 1. **AI Streaming**: Uses Vercel AI SDK's streaming capabilities for real-time responses 2. **GenerativeUI**: Dynamic UI components generated based on AI responses 3. **Type Safety**: Strict TypeScript configuration with comprehensive type definitions in `/lib/types/` 4. **Schema Validation**: Zod schemas in `/lib/schema/` for data validation 5. **Error Handling**: Comprehensive error boundaries and fallback UI components ## Testing Approach - Unit and integration tests with Vitest - Test files located alongside source files with `.test.ts` or `.test.tsx` extension - **Run `bun run test` to execute all tests** (NOT `bun test` - that uses Bun's built-in test runner which lacks Vitest features) - Run `bun run test:watch` for development with watch mode - CI automatically runs `bun run test` to ensure all tests pass ## Pre-PR Requirements Before creating a pull request, you MUST ensure all of the following checks pass: 1. **Linting**: Run `bun lint` and fix all ESLint errors and warnings (includes import sorting) 2. **Type checking**: Run `bun typecheck` to ensure no TypeScript errors 3. **Formatting**: Run `bun format:check` to verify code formatting (or `bun format` to auto-fix) 4. **Build**: Run `bun run build` to ensure the application builds successfully 5. **Tests**: Run `bun run test` to ensure all tests pass These checks are enforced in CI/CD and PRs will fail if any of these steps don't pass. Note: Import sorting is handled by ESLint using `eslint-plugin-simple-import-sort`. Run `bun lint --fix` to automatically sort imports according to the configured order. ## Model Configuration Models are defined in `public/config/models.json` with: - `id`: Model identifier - `provider`: Display name - `providerId`: Provider key for API routing - `enabled`: Toggle availability - `toolCallType`: "native" or "manual" for function calling - `toolCallModel`: Optional override for tool calls ## Database Management - Run `bun migrate` to apply database migrations - Migrations are located in `/drizzle/` directory - Schema changes should be made in `/lib/db/schema.ts` - Use Drizzle Kit for generating migrations ## MCP (Model Context Protocol) Integration This project supports MCP for enhanced AI assistant integration with Next.js 16. ### Built-in Next.js MCP Server Next.js 16 provides a built-in MCP server at `http://localhost:3000/_next/mcp` when the dev server is running. **Available Tools:** - `get_project_metadata` - Get project path and dev server URL - `get_errors` - Retrieve current error state (global errors, runtime errors, build errors) - `get_page_metadata` - Get runtime metadata about current page renders - `get_logs` - Access Next.js development log file path - `get_server_action_by_id` - Locate Server Actions by ID **Usage:** 1. Start the dev server: `bun dev` 2. MCP endpoint is automatically available at `/_next/mcp` 3. AI assistants can query real-time app state, errors, and logs ### Next DevTools MCP (External) The project includes `.mcp.json` configuration for the Next DevTools MCP package, which provides: - Next.js knowledge base access - Automated migration tools - Cache optimization guides - Browser testing capabilities **Setup:** The `.mcp.json` file in the project root enables team-wide MCP tool sharing. AI assistants like Claude Code will prompt for approval before using project-scoped servers. **Benefits:** - Real-time access to application internal state - Improved debugging and error diagnostics - Context-aware code suggestions - Live application state querying ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. [homepage]: https://www.contributor-covenant.org ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Morphic Thank you for your interest in contributing to Morphic! This document provides guidelines and instructions for contributing. ## Code of Conduct By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). ## How to Contribute ### Reporting Issues - Check if the issue already exists in our [GitHub Issues](https://github.com/miurla/morphic/issues) - Use the issue templates when creating a new issue - Provide as much context as possible ### Pull Requests 1. Fork the repository 2. Create a new branch from `main`: ```bash git checkout -b feat/your-feature-name ``` 3. Make your changes 4. Commit your changes using conventional commits: ```bash git commit -m "feat: add new feature" ``` 5. Push to your fork 6. Open a Pull Request ### Commit Convention We use conventional commits. Examples: - `feat: add new feature` - `fix: resolve issue with X` - `docs: update README` - `chore: update dependencies` - `refactor: improve code structure` ### Development Setup Follow the [Quickstart](README.md#-quickstart) guide in the README to set up your development environment. ## License By contributing, you agree that your contributions will be licensed under the Apache-2.0 License. ================================================ FILE: Dockerfile ================================================ # Build stage - Use Node for Next.js 16 compatibility (Bun lacks worker_threads support on arm64) FROM node:22-slim AS builder WORKDIR /app # Install bun for dependency management RUN npm install -g bun # Install dependencies (separated for better cache utilization) COPY package.json bun.lock ./ RUN bun install # Copy source code and build COPY . . RUN npx next telemetry disable ENV DATABASE_URL=postgresql://user:pass@localhost:5432/db RUN npm run build # Runtime stage FROM oven/bun:1.2.12 AS runner WORKDIR /app # Copy only necessary files from builder COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/bun.lock ./bun.lock COPY --from=builder /app/node_modules ./node_modules # Copy migration files and scripts COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/lib/db ./lib/db COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts # Create entrypoint script for database migration RUN echo '#!/bin/sh\n\ set -e\n\ echo "Running database migrations..."\n\ bun run migrate\n\ echo "Migrations completed. Starting server..."\n\ exec "$@"\n' > /app/docker-entrypoint.sh && chmod +x /app/docker-entrypoint.sh # Start production server with migration ENTRYPOINT ["/app/docker-entrypoint.sh"] CMD ["bun", "start", "-H", "0.0.0.0"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2024 Yoshiki Miura Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ <div align="center"> # Morphic An AI-powered search engine with a generative UI. [![DeepWiki](https://img.shields.io/badge/DeepWiki-miurla%2Fmorphic-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/miurla/morphic) [![GitHub stars](https://img.shields.io/github/stars/miurla/morphic?style=flat&colorA=000000&colorB=000000)](https://github.com/miurla/morphic/stargazers) [![GitHub forks](https://img.shields.io/github/forks/miurla/morphic?style=flat&colorA=000000&colorB=000000)](https://github.com/miurla/morphic/network/members) <a href="https://vercel.com/oss"> <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" /> </a> <br /> <br /> <a href="https://trendshift.io/repositories/9207" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9207" alt="miurla%2Fmorphic | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <img src="./public/screenshot-2026-02-07.png" /> </div> ## 🗂️ Overview - 🛠 [Features](#-features) - 🧱 [Stack](#-stack) - 🚀 [Quickstart](#-quickstart) - 🌐 [Deploy](#-deploy) - 👥 [Contributing](#-contributing) - 📄 [License](#-license) 📝 Explore AI-generated documentation on [DeepWiki](https://deepwiki.com/miurla/morphic) ## 🛠 Features ### Core Features - AI-powered search with GenerativeUI - Natural language question understanding - Multiple search providers support (Tavily, Brave, SearXNG, Exa) - Search modes: Quick, Planning, and Adaptive - Model type selection: Speed vs Quality - Inspector panel for tool execution and AI processing details ### Authentication - User authentication powered by [Supabase Auth](https://supabase.com/docs/guides/auth) ### Guest Mode - Allow users to try the app without creating an account - No chat history stored for guests (ephemeral sessions) - Optional daily rate limit per IP address - Enable with `ENABLE_GUEST_CHAT=true` ### Chat & History - Chat history automatically stored in PostgreSQL database - Share search results with unique URLs - Message feedback system - File upload support ### AI Providers - OpenAI (Default) - Anthropic Claude - Google Gemini - Vercel AI Gateway - Ollama Models are configured in `config/models/*.json` with profile-based settings. When using non-OpenAI providers, update the model configuration files with compatible model IDs. See [Configuration Guide](docs/CONFIGURATION.md) for details. ### Search Capabilities - URL-specific search - Content extraction with Tavily or Jina - Citation tracking and display - Self-hosted search with SearXNG support ### Additional Features - Docker deployment ready - Browser search engine integration - LLM observability with Langfuse (optional) - Todo tracking for complex tasks ## 🧱 Stack ### Core Framework - [Next.js](https://nextjs.org/) - React framework with App Router - [TypeScript](https://www.typescriptlang.org/) - Type-safe development - [Vercel AI SDK](https://ai-sdk.dev) - TypeScript toolkit for building AI-powered applications ### Authentication & Authorization - [Supabase](https://supabase.com/) - User authentication and backend services ### AI & Search - [OpenAI](https://openai.com/) - Default AI provider (Optional: Google AI, Anthropic) - [Tavily AI](https://tavily.com/) - AI-optimized search with context - [Brave Search](https://brave.com/search/api/) - Traditional web search results - Tavily alternatives: - [SearXNG](https://docs.searxng.org/) - Self-hosted search - [Exa](https://exa.ai/) - Meaning-based search powered by embeddings - [Firecrawl](https://firecrawl.dev/) - Web, news, and image search with crawling, scraping, LLM-ready extraction, and [open source](https://github.com/firecrawl/firecrawl). ### Data Storage - [PostgreSQL](https://www.postgresql.org/) - Primary database (supports Neon, Supabase, or standard PostgreSQL) - [Drizzle ORM](https://orm.drizzle.team/) - Type-safe database ORM - [Cloudflare R2](https://developers.cloudflare.com/r2/) - File storage (optional) ### UI & Styling - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework - [shadcn/ui](https://ui.shadcn.com/) - Re-usable components - [Radix UI](https://www.radix-ui.com/) - Unstyled, accessible components - [Lucide Icons](https://lucide.dev/) - Beautiful & consistent icons ## 🚀 Quickstart ### 1. Fork and Clone repo Fork the repo to your Github account, then run the following command to clone the repo: ```bash git clone git@github.com:[YOUR_GITHUB_ACCOUNT]/morphic.git ``` ### 2. Install dependencies ```bash cd morphic bun install ``` ### 3. Configure environment variables ```bash cp .env.local.example .env.local ``` Fill in the required environment variables in `.env.local`: ```bash OPENAI_API_KEY=your_openai_key TAVILY_API_KEY=your_tavily_key ``` ### 4. Run app locally ```bash bun dev ``` Visit http://localhost:3000 in your browser. **Note**: By default, Morphic runs without a database or authentication. To enable chat history, authentication, and other features, see [CONFIGURATION.md](./docs/CONFIGURATION.md). For Docker setup, see the [Docker Guide](./docs/DOCKER.md). ## 🌐 Deploy Host your own live version of Morphic with Vercel or Docker. ### Vercel [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmiurla%2Fmorphic&env=DATABASE_URL,OPENAI_API_KEY,TAVILY_API_KEY,BRAVE_SEARCH_API_KEY) **Note**: For Vercel deployments, set `ENABLE_AUTH=true` and configure Supabase authentication to secure your deployment. ### Docker See the [Docker Guide](./docs/DOCKER.md) for prebuilt images, Docker Compose setup, and deployment instructions. ## 👥 Contributing We welcome contributions to Morphic! Whether it's bug reports, feature requests, or pull requests, all contributions are appreciated. Please see our [Contributing Guide](CONTRIBUTING.md) for details on: - How to submit issues - How to submit pull requests - Commit message conventions - Development setup ## 📄 License This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. ================================================ FILE: app/api/advanced-search/route.ts ================================================ import { NextResponse } from 'next/server' import { Redis } from '@upstash/redis' import http from 'http' import { Agent } from 'http' import https from 'https' import { JSDOM, VirtualConsole } from 'jsdom' import { createClient } from 'redis' import { SearchResultItem, SearXNGResponse, SearXNGResult, SearXNGSearchResults } from '@/lib/types' /** * Maximum number of results to fetch from SearXNG. * Increasing this value can improve result quality but may impact performance. * In advanced search mode, this is multiplied by SEARXNG_CRAWL_MULTIPLIER for initial fetching. */ const SEARXNG_MAX_RESULTS = Math.max( 10, Math.min(100, parseInt(process.env.SEARXNG_MAX_RESULTS || '50', 10)) ) const CACHE_TTL = 3600 // Cache time-to-live in seconds (1 hour) const CACHE_EXPIRATION_CHECK_INTERVAL = 3600000 // 1 hour in milliseconds let redisClient: Redis | ReturnType<typeof createClient> | null = null // Initialize Redis client based on environment variables async function initializeRedisClient() { if (redisClient) return redisClient const upstashRedisRestUrl = process.env.UPSTASH_REDIS_REST_URL const upstashRedisRestToken = process.env.UPSTASH_REDIS_REST_TOKEN // Use Upstash Redis if credentials are provided if (upstashRedisRestUrl && upstashRedisRestToken) { redisClient = new Redis({ url: upstashRedisRestUrl, token: upstashRedisRestToken }) return redisClient } // Otherwise, try to use local Redis (for Docker/SearXNG usage) try { const localRedisUrl = process.env.LOCAL_REDIS_URL || 'redis://localhost:6379' const client = createClient({ url: localRedisUrl }) await client.connect() redisClient = client } catch (error) { console.warn( 'Failed to connect to local Redis. Advanced search caching disabled.', error ) redisClient = null } return redisClient } // Function to get cached results async function getCachedResults( cacheKey: string ): Promise<SearXNGSearchResults | null> { try { const client = await initializeRedisClient() if (!client) return null let cachedData: string | null if (client instanceof Redis) { cachedData = await client.get(cacheKey) } else { cachedData = await client.get(cacheKey) } if (cachedData) { console.log(`Cache hit for key: ${cacheKey}`) return JSON.parse(cachedData) } else { console.log(`Cache miss for key: ${cacheKey}`) return null } } catch (error) { console.error('Redis cache error:', error) return null } } // Function to set cached results with error handling and logging async function setCachedResults( cacheKey: string, results: SearXNGSearchResults ): Promise<void> { try { const client = await initializeRedisClient() if (!client) return const serializedResults = JSON.stringify(results) if (client instanceof Redis) { await client.set(cacheKey, serializedResults, { ex: CACHE_TTL }) } else { await client.set(cacheKey, serializedResults, { EX: CACHE_TTL }) } console.log(`Cached results for key: ${cacheKey}`) } catch (error) { console.error('Redis cache error:', error) } } // Function to periodically clean up expired cache entries async function cleanupExpiredCache() { try { const client = await initializeRedisClient() if (!client) return const keys = await client.keys('search:*') for (const key of keys) { const ttl = await client.ttl(key) if (ttl <= 0) { await client.del(key) console.log(`Removed expired cache entry: ${key}`) } } } catch (error) { console.error('Cache cleanup error:', error) } } // Set up periodic cache cleanup setInterval(cleanupExpiredCache, CACHE_EXPIRATION_CHECK_INTERVAL) export async function POST(request: Request) { const { query, maxResults, searchDepth, includeDomains, excludeDomains } = await request.json() const SEARXNG_DEFAULT_DEPTH = process.env.SEARXNG_DEFAULT_DEPTH || 'basic' try { const cacheKey = `search:${query}:${maxResults}:${searchDepth}:${ Array.isArray(includeDomains) ? includeDomains.join(',') : '' }:${Array.isArray(excludeDomains) ? excludeDomains.join(',') : ''}` // Try to get cached results const cachedResults = await getCachedResults(cacheKey) if (cachedResults) { return NextResponse.json(cachedResults) } // If not cached, perform the search const results = await advancedSearchXNGSearch( query, Math.min(maxResults, SEARXNG_MAX_RESULTS), searchDepth || SEARXNG_DEFAULT_DEPTH, Array.isArray(includeDomains) ? includeDomains : [], Array.isArray(excludeDomains) ? excludeDomains : [] ) // Cache the results await setCachedResults(cacheKey, results) return NextResponse.json(results) } catch (error) { console.error('Advanced search error:', error) return NextResponse.json( { message: 'Internal Server Error', error: error instanceof Error ? error.message : String(error), query: query, results: [], images: [], number_of_results: 0 }, { status: 500 } ) } } async function advancedSearchXNGSearch( query: string, maxResults: number = 10, searchDepth: 'basic' | 'advanced' = 'advanced', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearXNGSearchResults> { const apiUrl = process.env.SEARXNG_API_URL if (!apiUrl) { throw new Error('SEARXNG_API_URL is not set in the environment variables') } const SEARXNG_ENGINES = process.env.SEARXNG_ENGINES || 'google,bing,duckduckgo,wikipedia' const SEARXNG_TIME_RANGE = process.env.SEARXNG_TIME_RANGE || 'None' const SEARXNG_SAFESEARCH = process.env.SEARXNG_SAFESEARCH || '0' const SEARXNG_CRAWL_MULTIPLIER = parseInt( process.env.SEARXNG_CRAWL_MULTIPLIER || '4', 10 ) try { const url = new URL(`${apiUrl}/search`) url.searchParams.append('q', query) url.searchParams.append('format', 'json') url.searchParams.append('categories', 'general,images') // Add time_range if it's not 'None' if (SEARXNG_TIME_RANGE !== 'None') { url.searchParams.append('time_range', SEARXNG_TIME_RANGE) } url.searchParams.append('safesearch', SEARXNG_SAFESEARCH) url.searchParams.append('engines', SEARXNG_ENGINES) const resultsPerPage = 10 const pageno = Math.ceil(maxResults / resultsPerPage) url.searchParams.append('pageno', String(pageno)) //console.log('SearXNG API URL:', url.toString()) // Log the full URL for debugging const data: | SearXNGResponse | { error: string; status: number; data: string } = await fetchJsonWithRetry(url.toString(), 3) if ('error' in data) { console.error('Invalid response from SearXNG:', data) throw new Error( `Invalid response from SearXNG: ${data.error}. Status: ${data.status}. Data: ${data.data}` ) } if (!data || !Array.isArray(data.results)) { console.error('Invalid response structure from SearXNG:', data) throw new Error('Invalid response structure from SearXNG') } let generalResults = data.results.filter( (result: SearXNGResult) => result && !result.img_src ) // Apply domain filtering manually if (includeDomains.length > 0 || excludeDomains.length > 0) { generalResults = generalResults.filter(result => { const domain = new URL(result.url).hostname return ( (includeDomains.length === 0 || includeDomains.some(d => domain.includes(d))) && (excludeDomains.length === 0 || !excludeDomains.some(d => domain.includes(d))) ) }) } if (searchDepth === 'advanced') { const crawledResults = await Promise.all( generalResults .slice(0, maxResults * SEARXNG_CRAWL_MULTIPLIER) .map(result => crawlPage(result, query)) ) generalResults = crawledResults .filter(result => result !== null && isQualityContent(result.content)) .map(result => result as SearXNGResult) const MIN_RELEVANCE_SCORE = 10 generalResults = generalResults .map(result => ({ ...result, score: calculateRelevanceScore(result, query) })) .filter(result => result.score >= MIN_RELEVANCE_SCORE) .sort((a, b) => b.score - a.score) .slice(0, maxResults) } generalResults = generalResults.slice(0, maxResults) const imageResults = (data.results || []) .filter((result: SearXNGResult) => result && result.img_src) .slice(0, maxResults) return { results: generalResults.map( (result: SearXNGResult): SearchResultItem => ({ title: result.title || '', url: result.url || '', content: result.content || '' }) ), query: data.query || query, images: imageResults .map((result: SearXNGResult) => { const imgSrc = result.img_src || '' return imgSrc.startsWith('http') ? imgSrc : `${apiUrl}${imgSrc}` }) .filter(Boolean), number_of_results: data.number_of_results || generalResults.length } } catch (error) { console.error('SearchXNG API error:', error) return { results: [], query: query, images: [], number_of_results: 0 } } } async function crawlPage( result: SearXNGResult, query: string ): Promise<SearXNGResult> { try { const html = await fetchHtmlWithTimeout(result.url, 20000) // virtual console to suppress JSDOM warnings const virtualConsole = new VirtualConsole() virtualConsole.on('error', () => {}) virtualConsole.on('warn', () => {}) const dom = new JSDOM(html, { runScripts: 'outside-only', resources: 'usable', virtualConsole }) const document = dom.window.document // Remove script, style, nav, header, and footer elements document .querySelectorAll('script, style, nav, header, footer') .forEach((el: Element) => el.remove()) const mainContent = document.querySelector('main') || document.querySelector('article') || document.querySelector('.content') || document.querySelector('#content') || document.body if (mainContent) { // Prioritize specific content elements const priorityElements = mainContent.querySelectorAll('h1, h2, h3, p') let extractedText = Array.from(priorityElements) .map(el => el.textContent?.trim()) .filter(Boolean) .join('\n\n') // If not enough content, fall back to other elements if (extractedText.length < 500) { const contentElements = mainContent.querySelectorAll( 'h4, h5, h6, li, td, th, blockquote, pre, code' ) extractedText += '\n\n' + Array.from(contentElements) .map(el => el.textContent?.trim()) .filter(Boolean) .join('\n\n') } // Extract metadata const metaDescription = document .querySelector('meta[name="description"]') ?.getAttribute('content') || '' const metaKeywords = document .querySelector('meta[name="keywords"]') ?.getAttribute('content') || '' const ogTitle = document .querySelector('meta[property="og:title"]') ?.getAttribute('content') || '' const ogDescription = document .querySelector('meta[property="og:description"]') ?.getAttribute('content') || '' // Combine metadata with extracted text extractedText = `${result.title}\n\n${ogTitle}\n\n${metaDescription}\n\n${ogDescription}\n\n${metaKeywords}\n\n${extractedText}` // Limit the extracted text to 10000 characters extractedText = extractedText.substring(0, 10000) // Highlight query terms in the content result.content = highlightQueryTerms(extractedText, query) // Extract publication date const publishedDate = extractPublicationDate(document) if (publishedDate) { result.publishedDate = publishedDate.toISOString() } } return result } catch (error) { console.error(`Error crawling ${result.url}:`, error) return { ...result, content: result.content || 'Content unavailable due to crawling error.' } } } function highlightQueryTerms(content: string, query: string): string { try { const terms = query .toLowerCase() .split(/\s+/) .filter(term => term.length > 2) .map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) // Escape special characters let highlightedContent = content terms.forEach(term => { const regex = new RegExp(`\\b${term}\\b`, 'gi') highlightedContent = highlightedContent.replace( regex, match => `<mark>${match}</mark>` ) }) return highlightedContent } catch (error) { //console.error('Error in highlightQueryTerms:', error) return content // Return original content if highlighting fails } } function calculateRelevanceScore(result: SearXNGResult, query: string): number { try { const lowercaseContent = result.content.toLowerCase() const lowercaseQuery = query.toLowerCase() const queryWords = lowercaseQuery .split(/\s+/) .filter(word => word.length > 2) .map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) // Escape special characters let score = 0 // Check for exact phrase match if (lowercaseContent.includes(lowercaseQuery)) { score += 30 } // Check for individual word matches queryWords.forEach(word => { const regex = new RegExp(`\\b${word}\\b`, 'g') const wordCount = (lowercaseContent.match(regex) || []).length score += wordCount * 3 }) // Boost score for matches in the title const lowercaseTitle = result.title.toLowerCase() if (lowercaseTitle.includes(lowercaseQuery)) { score += 20 } queryWords.forEach(word => { const regex = new RegExp(`\\b${word}\\b`, 'g') if (lowercaseTitle.match(regex)) { score += 10 } }) // Boost score for recent content (if available) if (result.publishedDate) { const publishDate = new Date(result.publishedDate) const now = new Date() const daysSincePublished = (now.getTime() - publishDate.getTime()) / (1000 * 3600 * 24) if (daysSincePublished < 30) { score += 15 } else if (daysSincePublished < 90) { score += 10 } else if (daysSincePublished < 365) { score += 5 } } // Penalize very short content if (result.content.length < 200) { score -= 10 } else if (result.content.length > 1000) { score += 5 } // Boost score for content with more highlighted terms const highlightCount = (result.content.match(/<mark>/g) || []).length score += highlightCount * 2 return score } catch (error) { //console.error('Error in calculateRelevanceScore:', error) return 0 // Return 0 if scoring fails } } function extractPublicationDate(document: Document): Date | null { const dateSelectors = [ 'meta[name="article:published_time"]', 'meta[property="article:published_time"]', 'meta[name="publication-date"]', 'meta[name="date"]', 'time[datetime]', 'time[pubdate]' ] for (const selector of dateSelectors) { const element = document.querySelector(selector) if (element) { const dateStr = element.getAttribute('content') || element.getAttribute('datetime') || element.getAttribute('pubdate') if (dateStr) { const date = new Date(dateStr) if (!isNaN(date.getTime())) { return date } } } } return null } const httpAgent = new http.Agent({ keepAlive: true }) const httpsAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: true // change to false if you want to ignore SSL certificate errors //but use this with caution. }) async function fetchJsonWithRetry(url: string, retries: number): Promise<any> { for (let i = 0; i < retries; i++) { try { return await fetchJson(url) } catch (error) { if (i === retries - 1) throw error await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))) } } } function fetchJson(url: string): Promise<any> { return new Promise((resolve, reject) => { const protocol = url.startsWith('https:') ? https : http const agent = url.startsWith('https:') ? httpsAgent : httpAgent const request = protocol.get(url, { agent }, res => { let data = '' res.on('data', chunk => { data += chunk }) res.on('end', () => { try { // Check if the response is JSON if (res.headers['content-type']?.includes('application/json')) { resolve(JSON.parse(data)) } else { // If not JSON, return an object with the raw data and status resolve({ error: 'Invalid JSON response', status: res.statusCode, data: data.substring(0, 200) // Include first 200 characters of the response }) } } catch (e) { reject(e) } }) }) request.on('error', reject) request.on('timeout', () => { request.destroy() reject(new Error('Request timed out')) }) request.setTimeout(15000) // 15 second timeout }) } async function fetchHtmlWithTimeout( url: string, timeoutMs: number ): Promise<string> { try { return await Promise.race([ fetchHtml(url), timeout(timeoutMs, `Fetching ${url} timed out after ${timeoutMs}ms`) ]) } catch (error) { console.error(`Error fetching ${url}:`, error) const errorMessage = error instanceof Error ? error.message : String(error) return `<html><body>Error fetching content: ${errorMessage}</body></html>` } } function fetchHtml(url: string): Promise<string> { return new Promise((resolve, reject) => { const protocol = url.startsWith('https:') ? https : http const agent = url.startsWith('https:') ? httpsAgent : httpAgent const request = protocol.get(url, { agent }, res => { if ( res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location ) { // Handle redirects fetchHtml(new URL(res.headers.location, url).toString()) .then(resolve) .catch(reject) return } let data = '' res.on('data', chunk => { data += chunk }) res.on('end', () => resolve(data)) }) request.on('error', error => { //console.error(`Error fetching ${url}:`, error) reject(error) }) request.on('timeout', () => { request.destroy() //reject(new Error(`Request timed out for ${url}`)) resolve('') }) request.setTimeout(10000) // 10 second timeout }) } function timeout(ms: number, message: string): Promise<never> { return new Promise((_, reject) => { setTimeout(() => { reject(new Error(message)) }, ms) }) } function isQualityContent(text: string): boolean { const words = text.split(/\s+/).length const sentences = text.split(/[.!?]+/).length const avgWordsPerSentence = words / sentences return ( words > 50 && sentences > 3 && avgWordsPerSentence > 5 && avgWordsPerSentence < 30 && !text.includes('Content unavailable due to crawling error') && !text.includes('Error fetching content:') ) } ================================================ FILE: app/api/chat/route.ts ================================================ import { revalidateTag } from 'next/cache' import { cookies } from 'next/headers' import { loadChat } from '@/lib/actions/chat' import { calculateConversationTurn, trackChatEvent } from '@/lib/analytics' import { getCurrentUserId } from '@/lib/auth/get-current-user' import { checkAndEnforceOverallChatLimit } from '@/lib/rate-limit/chat-limits' import { checkAndEnforceGuestLimit } from '@/lib/rate-limit/guest-limit' import { createChatStreamResponse } from '@/lib/streaming/create-chat-stream-response' import { createEphemeralChatStreamResponse } from '@/lib/streaming/create-ephemeral-chat-stream-response' import { SearchMode } from '@/lib/types/search' import { selectModel } from '@/lib/utils/model-selection' import { perfLog, perfTime } from '@/lib/utils/perf-logging' import { resetAllCounters } from '@/lib/utils/perf-tracking' import { isProviderEnabled } from '@/lib/utils/registry' export const maxDuration = 300 export async function POST(req: Request) { const startTime = performance.now() const abortSignal = req.signal // Reset counters for new request (development only) if (process.env.ENABLE_PERF_LOGGING === 'true') { resetAllCounters() } try { const body = await req.json() const { message, messages, chatId, trigger, messageId, isNewChat } = body perfLog( `API Route - Start: chatId=${chatId}, trigger=${trigger}, isNewChat=${isNewChat}` ) // Handle different triggers using AI SDK standard values if (trigger === 'regenerate-message') { if (!messageId) { return new Response('messageId is required for regeneration', { status: 400, statusText: 'Bad Request' }) } } else if (trigger === 'submit-message') { if (!message) { return new Response('message is required for submission', { status: 400, statusText: 'Bad Request' }) } } const referer = req.headers.get('referer') const isSharePage = referer?.includes('/share/') const authStart = performance.now() const userId = await getCurrentUserId() perfTime('Auth completed', authStart) if (isSharePage) { return new Response('Chat API is not available on share pages', { status: 403, statusText: 'Forbidden' }) } const guestChatEnabled = process.env.ENABLE_GUEST_CHAT === 'true' const isGuest = !userId if (isGuest && !guestChatEnabled) { return new Response('Authentication required', { status: 401, statusText: 'Unauthorized' }) } if (isGuest) { const forwardedFor = req.headers.get('x-forwarded-for') || '' const ip = forwardedFor.split(',')[0]?.trim() || req.headers.get('x-real-ip') || null const guestLimitResponse = await checkAndEnforceGuestLimit(ip) if (guestLimitResponse) return guestLimitResponse } const cookieStore = await cookies() // Get search mode from cookie const searchModeCookie = cookieStore.get('searchMode')?.value const searchMode: SearchMode = searchModeCookie && ['quick', 'adaptive'].includes(searchModeCookie) ? (searchModeCookie as SearchMode) : 'quick' const isCloudDeployment = process.env.MORPHIC_CLOUD_DEPLOYMENT === 'true' const forceSpeed = isGuest || isCloudDeployment const modelCookieStore = forceSpeed ? ({ get: (name: string) => name === 'modelType' ? ({ value: 'speed' } as const) : cookieStore.get(name) } as typeof cookieStore) : cookieStore // Select the appropriate model based on model type preference and search mode const selectedModel = selectModel({ cookieStore: modelCookieStore, searchMode }) if (!isProviderEnabled(selectedModel.providerId)) { return new Response( `Selected provider is not enabled ${selectedModel.providerId}`, { status: 404, statusText: 'Not Found' } ) } // Resolve model type from cookie (forced to speed for guests and cloud) const modelTypeCookie = cookieStore.get('modelType')?.value const resolvedModelType = modelTypeCookie === 'quality' || modelTypeCookie === 'speed' ? modelTypeCookie : undefined const modelType = forceSpeed ? 'speed' : resolvedModelType if (!isGuest) { const overallLimitResponse = await checkAndEnforceOverallChatLimit(userId) if (overallLimitResponse) return overallLimitResponse } const streamStart = performance.now() perfLog( `createChatStreamResponse - Start: model=${selectedModel.providerId}:${selectedModel.id}, searchMode=${searchMode}, modelType=${modelType}` ) const response = isGuest ? await createEphemeralChatStreamResponse({ messages: Array.isArray(messages) ? messages : [], model: selectedModel, abortSignal, searchMode, modelType, chatId }) : await createChatStreamResponse({ message, model: selectedModel, chatId, userId: userId, // userId is guaranteed to be non-null after authentication check above trigger, messageId, abortSignal, isNewChat, searchMode, modelType }) perfTime('createChatStreamResponse resolved', streamStart) // Track analytics event (non-blocking) // Calculate conversation turn by loading chat history ;(async () => { try { let conversationTurn = 1 // Default for new chats // For existing chats, load history and calculate turn number if (!isNewChat && !isGuest) { const chat = await loadChat(chatId, userId) if (chat?.messages) { // Add 1 to account for the current message being sent conversationTurn = calculateConversationTurn(chat.messages) + 1 } } if (!isGuest && userId) { await trackChatEvent({ searchMode, modelType: modelTypeCookie === 'quality' ? 'quality' : 'speed', conversationTurn, isNewChat: isNewChat ?? false, trigger: (trigger as 'submit-message' | 'regenerate-message') ?? 'submit-message', chatId, userId, modelId: selectedModel.id }) } } catch (error) { // Log error but don't throw - analytics should never break the app console.error('Analytics tracking failed:', error) } })() // Invalidate the cache for this specific chat after creating the response // This ensures the next load will get fresh data if (chatId && !isGuest) { revalidateTag(`chat-${chatId}`, 'max') } const totalTime = performance.now() - startTime perfLog(`Total API route time: ${totalTime.toFixed(2)}ms`) perfLog(`=== Summary ===`) perfLog(`Chat Type: ${isNewChat ? 'NEW' : 'EXISTING'}`) perfLog(`Total Time: ${totalTime.toFixed(2)}ms`) perfLog(`================`) return response } catch (error) { console.error('API route error:', error) return new Response('Error processing your request', { status: 500, statusText: 'Internal Server Error' }) } } ================================================ FILE: app/api/chats/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { getChatsPage } from '@/lib/actions/chat' import { Chat as DBChat } from '@/lib/db/schema' interface ChatPageResponse { chats: DBChat[] nextOffset: number | null } export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const offset = parseInt(searchParams.get('offset') || '0', 10) const limit = parseInt(searchParams.get('limit') || '20', 10) try { const result = await getChatsPage(limit, offset) return NextResponse.json<ChatPageResponse>(result) } catch (error) { console.error('API route error fetching chats:', error) return NextResponse.json<ChatPageResponse>( { chats: [], nextOffset: null }, { status: 500 } ) } } ================================================ FILE: app/api/feedback/__tests__/route.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock Next.js cookies API vi.mock('next/headers', () => ({ cookies: vi.fn(() => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn(), getAll: vi.fn(() => []) })) })) // Mock Supabase vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(() => ({ auth: { getUser: vi.fn(() => Promise.resolve({ data: { user: null }, error: null }) ) } })) })) // Mock the modules vi.mock('@/lib/actions/feedback', () => ({ updateMessageFeedback: vi.fn() })) vi.mock('@/lib/utils/telemetry', () => ({ isTracingEnabled: vi.fn(() => false) })) vi.mock('langfuse', () => ({ Langfuse: vi.fn(() => ({ score: vi.fn(), flushAsync: vi.fn(() => Promise.resolve()) })) })) // Import after mocking import { Langfuse } from 'langfuse' import { updateMessageFeedback } from '@/lib/actions/feedback' import { isTracingEnabled } from '@/lib/utils/telemetry' import { POST } from '../route' describe('Feedback API Route', () => { beforeEach(() => { vi.clearAllMocks() }) describe('POST /api/feedback', () => { it('should record feedback successfully', async () => { vi.mocked(isTracingEnabled).mockReturnValue(true) vi.mocked(updateMessageFeedback).mockResolvedValue({ success: true }) const mockScore = vi.fn() const mockFlush = vi.fn().mockResolvedValue(undefined) vi.mocked(Langfuse).mockImplementation( () => ({ score: mockScore, flushAsync: mockFlush }) as any ) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: 1, comment: 'Great!', messageId: 'test-message-id' }) }) const response = await POST(request) const text = await response.text() expect(response.status).toBe(200) expect(text).toBe('Feedback recorded successfully') expect(mockScore).toHaveBeenCalledWith({ traceId: 'test-trace-id', name: 'user_feedback', value: 1, comment: 'Great!' }) expect(mockFlush).toHaveBeenCalled() expect(updateMessageFeedback).toHaveBeenCalledWith( 'test-message-id', 1, null ) }) it('should handle negative feedback', async () => { vi.mocked(isTracingEnabled).mockReturnValue(true) vi.mocked(updateMessageFeedback).mockResolvedValue({ success: true }) const mockScore = vi.fn() const mockFlush = vi.fn().mockResolvedValue(undefined) vi.mocked(Langfuse).mockImplementation( () => ({ score: mockScore, flushAsync: mockFlush }) as any ) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: -1, messageId: 'test-message-id' }) }) const response = await POST(request) expect(response.status).toBe(200) expect(mockScore).toHaveBeenCalledWith({ traceId: 'test-trace-id', name: 'user_feedback', value: -1, comment: undefined }) }) it('should return 400 for missing traceId', async () => { const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ score: 1, messageId: 'test-message-id' }) }) const response = await POST(request) const text = await response.text() expect(response.status).toBe(400) expect(text).toBe('traceId is required') }) it('should return 400 for invalid score', async () => { const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: 0, messageId: 'test-message-id' }) }) const response = await POST(request) const text = await response.text() expect(response.status).toBe(400) expect(text).toBe('score must be 1 (good) or -1 (bad)') }) it('should return 200 when tracing is disabled', async () => { vi.mocked(isTracingEnabled).mockReturnValue(false) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: 1, messageId: 'test-message-id' }) }) const response = await POST(request) const text = await response.text() expect(response.status).toBe(200) expect(text).toBe('Feedback tracking is not enabled') expect(updateMessageFeedback).not.toHaveBeenCalled() }) it('should continue even if database update fails', async () => { vi.mocked(isTracingEnabled).mockReturnValue(true) vi.mocked(updateMessageFeedback).mockResolvedValue({ success: false, error: 'Database error' }) const mockScore = vi.fn() const mockFlush = vi.fn().mockResolvedValue(undefined) vi.mocked(Langfuse).mockImplementation( () => ({ score: mockScore, flushAsync: mockFlush }) as any ) const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: 1, messageId: 'test-message-id' }) }) const response = await POST(request) expect(response.status).toBe(200) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error updating message feedback:', 'Database error' ) consoleErrorSpy.mockRestore() }) it('should work without messageId', async () => { vi.mocked(isTracingEnabled).mockReturnValue(true) const mockScore = vi.fn() const mockFlush = vi.fn().mockResolvedValue(undefined) vi.mocked(Langfuse).mockImplementation( () => ({ score: mockScore, flushAsync: mockFlush }) as any ) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId: 'test-trace-id', score: 1 }) }) const response = await POST(request) expect(response.status).toBe(200) expect(updateMessageFeedback).not.toHaveBeenCalled() }) it('should handle JSON parsing errors', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) const request = new Request('http://localhost:3000/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'invalid json' }) const response = await POST(request) const text = await response.text() expect(response.status).toBe(500) expect(text).toBe('Error recording feedback') expect(consoleErrorSpy).toHaveBeenCalled() consoleErrorSpy.mockRestore() }) }) }) ================================================ FILE: app/api/feedback/route.ts ================================================ import { Langfuse } from 'langfuse' import { updateMessageFeedback } from '@/lib/actions/feedback' import { createClient } from '@/lib/supabase/server' import { isTracingEnabled } from '@/lib/utils/telemetry' export async function POST(req: Request) { try { const body = await req.json() const { traceId, score, comment, messageId } = body if (!traceId) { return new Response('traceId is required', { status: 400, statusText: 'Bad Request' }) } if (score === undefined || (score !== 1 && score !== -1)) { return new Response('score must be 1 (good) or -1 (bad)', { status: 400, statusText: 'Bad Request' }) } // Check if tracing is enabled if (!isTracingEnabled()) { return new Response('Feedback tracking is not enabled', { status: 200 }) } // Initialize Langfuse client const langfuse = new Langfuse() // Send score to Langfuse langfuse.score({ traceId, name: 'user_feedback', value: score, comment }) // Flush to ensure the score is sent await langfuse.flushAsync() // Get current user for RLS context let userId: string | null = null const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (supabaseUrl && supabaseAnonKey) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() userId = user?.id || null } // Update the message metadata with the feedback score using the action if (messageId) { const result = await updateMessageFeedback(messageId, score, userId) if (!result.success) { console.error('Error updating message feedback:', result.error) // Continue even if database update fails } } return new Response('Feedback recorded successfully', { status: 200 }) } catch (error) { console.error('Error recording feedback:', error) return new Response('Error recording feedback', { status: 500, statusText: 'Internal Server Error' }) } } ================================================ FILE: app/api/upload/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { PutObjectCommand } from '@aws-sdk/client-s3' import { getCurrentUserId } from '@/lib/auth/get-current-user' import { getR2Client, R2_BUCKET_NAME, R2_PUBLIC_URL } from '@/lib/storage/r2-client' const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'] export async function POST(req: NextRequest) { try { const userId = await getCurrentUserId() if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const contentType = req.headers.get('content-type') || '' if (!contentType.includes('multipart/form-data')) { return NextResponse.json( { error: 'Invalid content type' }, { status: 400 } ) } const formData = await req.formData() const file = formData.get('file') as File const chatId = formData.get('chatId') as string if (!file) { return NextResponse.json({ error: 'File is required' }, { status: 400 }) } if (file.size > MAX_FILE_SIZE) { return NextResponse.json( { error: 'File too large (max 5MB)' }, { status: 400 } ) } if (!ALLOWED_TYPES.includes(file.type)) { return NextResponse.json( { error: 'Unsupported file type' }, { status: 400 } ) } const result = await uploadFileToR2(file, userId, chatId) return NextResponse.json({ success: true, file: result }, { status: 200 }) } catch (err: any) { console.error('Upload Error:', err) return NextResponse.json( { error: 'Upload failed', message: err.message }, { status: 500 } ) } } function sanitizeFilename(filename: string) { return filename.replace(/[^a-z0-9.\-_]/gi, '_').toLowerCase() } async function uploadFileToR2(file: File, userId: string, chatId: string) { const sanitizedFileName = sanitizeFilename(file.name) const filePath = `${userId}/chats/${chatId}/${Date.now()}-${sanitizedFileName}` try { const buffer = Buffer.from(await file.arrayBuffer()) const r2Client = getR2Client() await r2Client.send( new PutObjectCommand({ Bucket: R2_BUCKET_NAME, Key: filePath, Body: buffer, ContentType: file.type, CacheControl: 'max-age=3600' }) ) const publicUrl = `${R2_PUBLIC_URL}/${filePath}` return { filename: file.name, url: publicUrl, mediaType: file.type, type: 'file' } } catch (error: any) { throw new Error('Upload failed: ' + error.message) } } ================================================ FILE: app/auth/confirm/route.ts ================================================ import { redirect } from 'next/navigation' import { type NextRequest } from 'next/server' import { type EmailOtpType } from '@supabase/supabase-js' import { createClient } from '@/lib/supabase/server' export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null const next = searchParams.get('next') ?? '/' if (token_hash && type) { const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, token_hash }) if (!error) { // redirect user to specified redirect URL or root of app redirect(next) } else { // redirect the user to an error page with some instructions redirect(`/auth/error?error=${error?.message}`) } } // redirect the user to an error page with some instructions redirect(`/auth/error?error=No token hash or type`) } ================================================ FILE: app/auth/error/page.tsx ================================================ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' export default async function Page({ searchParams }: { searchParams: Promise<{ error: string }> }) { const params = await searchParams return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <div className="flex flex-col gap-6"> <Card> <CardHeader> <CardTitle className="text-2xl"> Sorry, something went wrong. </CardTitle> </CardHeader> <CardContent> {params?.error ? ( <p className="text-sm text-muted-foreground"> Code error: {params.error} </p> ) : ( <p className="text-sm text-muted-foreground"> An unspecified error occurred. </p> )} </CardContent> </Card> </div> </div> </div> ) } ================================================ FILE: app/auth/forgot-password/page.tsx ================================================ import { ForgotPasswordForm } from '@/components/forgot-password-form' export default function Page() { return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <ForgotPasswordForm /> </div> </div> ) } ================================================ FILE: app/auth/login/page.tsx ================================================ import { LoginForm } from '@/components/login-form' export default function Page() { return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <LoginForm /> </div> </div> ) } ================================================ FILE: app/auth/oauth/route.ts ================================================ import { NextResponse } from 'next/server' // The client you created from the Server-Side Auth instructions import { createClient } from '@/lib/supabase/server' export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') // if "next" is in param, use it as the redirect URL const next = searchParams.get('next') ?? '/' if (code) { const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer const isLocalEnv = process.env.NODE_ENV === 'development' if (isLocalEnv) { // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host return NextResponse.redirect(`${origin}${next}`) } else if (forwardedHost) { return NextResponse.redirect(`https://${forwardedHost}${next}`) } else { return NextResponse.redirect(`${origin}${next}`) } } } // return the user to an error page with instructions return NextResponse.redirect(`${origin}/auth/error`) } ================================================ FILE: app/auth/sign-up/page.tsx ================================================ import { SignUpForm } from '@/components/sign-up-form' export default function Page() { return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <SignUpForm /> </div> </div> ) } ================================================ FILE: app/auth/sign-up-success/page.tsx ================================================ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' export default function Page() { return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <div className="flex flex-col gap-6"> <Card> <CardHeader> <CardTitle className="text-2xl"> Thank you for signing up! </CardTitle> <CardDescription>Check your email to confirm</CardDescription> </CardHeader> <CardContent> <p className="text-sm text-muted-foreground"> You've successfully signed up. Please check your email to confirm your account before signing in. </p> </CardContent> </Card> </div> </div> </div> ) } ================================================ FILE: app/auth/update-password/page.tsx ================================================ import { UpdatePasswordForm } from '@/components/update-password-form' export default function Page() { return ( <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="w-full max-w-sm"> <UpdatePasswordForm /> </div> </div> ) } ================================================ FILE: app/globals.css ================================================ @import 'tailwindcss'; @source "../node_modules/streamdown/dist/index.js"; @custom-variant dark (&:is(.dark *)); :root { --background: oklch(0.99 0 0); --foreground: oklch(0 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0 0 0); --popover: oklch(0.99 0 0); --popover-foreground: oklch(0 0 0); --primary: oklch(0 0 0); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.94 0 0); --secondary-foreground: oklch(0 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.44 0 0); --accent: oklch(0.94 0 0); --accent-foreground: oklch(0 0 0); --destructive: oklch(0.63 0.19 23.03); --destructive-foreground: oklch(1 0 0); --border: oklch(0.92 0 0); --input: oklch(0.94 0 0); --ring: oklch(0 0 0); --chart-1: oklch(0.81 0.17 75.35); --chart-2: oklch(0.55 0.22 264.53); --chart-3: oklch(0.72 0 0); --chart-4: oklch(0.92 0 0); --chart-5: oklch(0.56 0 0); --sidebar: oklch(0.97 0 0); --sidebar-foreground: oklch(0 0 0); --sidebar-primary: oklch(0 0 0); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.94 0 0); --sidebar-accent-foreground: oklch(0 0 0); --sidebar-border: oklch(0.94 0 0); --sidebar-ring: oklch(0 0 0); --font-sans: Geist, sans-serif; --font-serif: Georgia, serif; --font-mono: Geist Mono, monospace; --radius: 0.5rem; --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); --tracking-normal: 0em; --spacing: 0.25rem; } .dark { --background: oklch(0 0 0); --foreground: oklch(1 0 0); --card: oklch(0.14 0 0); --card-foreground: oklch(1 0 0); --popover: oklch(0.18 0 0); --popover-foreground: oklch(1 0 0); --primary: oklch(1 0 0); --primary-foreground: oklch(0 0 0); --secondary: oklch(0.25 0 0); --secondary-foreground: oklch(1 0 0); --muted: oklch(0.23 0 0); --muted-foreground: oklch(0.72 0 0); --accent: oklch(0.32 0 0); --accent-foreground: oklch(1 0 0); --destructive: oklch(0.69 0.2 23.91); --destructive-foreground: oklch(0 0 0); --border: oklch(0.26 0 0); --input: oklch(0.32 0 0); --ring: oklch(0.72 0 0); --chart-1: oklch(0.81 0.17 75.35); --chart-2: oklch(0.58 0.21 260.84); --chart-3: oklch(0.56 0 0); --chart-4: oklch(0.44 0 0); --chart-5: oklch(0.92 0 0); --sidebar: oklch(0.23 0 0); --sidebar-foreground: oklch(1 0 0); --sidebar-primary: oklch(1 0 0); --sidebar-primary-foreground: oklch(0 0 0); --sidebar-accent: oklch(0.32 0 0); --sidebar-accent-foreground: oklch(1 0 0); --sidebar-border: oklch(0.32 0 0); --sidebar-ring: oklch(0.72 0 0); --font-sans: Geist, sans-serif; --font-serif: Georgia, serif; --font-mono: Geist Mono, monospace; --radius: 0.5rem; --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --font-sans: var(--font-sans); --font-mono: var(--font-mono); --font-serif: var(--font-serif); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --shadow-2xs: var(--shadow-2xs); --shadow-xs: var(--shadow-xs); --shadow-sm: var(--shadow-sm); --shadow: var(--shadow); --shadow-md: var(--shadow-md); --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; --animate-collapse-down: collapse-down 0.1s ease-in-out; --animate-collapse-up: collapse-up 0.1s ease-in-out; --animate-slide-in-right: slide-in-right 0.3s ease-out; --animate-slide-out-right: slide-out-right 0.3s ease-out; --animate-fade-in: fade-in 0.3s ease-out; @keyframes accordion-down { from { height: 0; } to { height: var(--radix-accordion-content-height); } } @keyframes accordion-up { from { height: var(--radix-accordion-content-height); } to { height: 0; } } @keyframes collapse-down { from { height: 0; opacity: 0; } to { height: var(--radix-collapsible-content-height); opacity: 1; } } @keyframes collapse-up { from { height: var(--radix-collapsible-content-height); opacity: 1; } to { height: 0; opacity: 0; } } @keyframes slide-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slide-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes blink { 0%, 100% { ry: 18; } 50% { ry: 0; } } @keyframes lookAround { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(10px); } 50% { transform: translateX(0); } 75% { transform: translateX(-10px); } } } @utility animate-blink { animation: blink 0.2s ease-in-out; } /* Animation utilities for dropdown menu */ @utility animate-in { animation-name: enter; animation-duration: 150ms; animation-fill-mode: both; } @utility animate-out { animation-name: exit; animation-duration: 150ms; animation-fill-mode: both; } @utility fade-in-0 { --enter-opacity: 0; } @utility fade-out-0 { --exit-opacity: 0; } @utility zoom-in-95 { --enter-scale: 0.95; } @utility zoom-out-95 { --exit-scale: 0.95; } @utility slide-in-from-top-2 { --enter-translate-y: -0.5rem; } @utility slide-in-from-bottom-2 { --enter-translate-y: 0.5rem; } @utility slide-in-from-left-2 { --enter-translate-x: -0.5rem; } @utility slide-in-from-right-2 { --enter-translate-x: 0.5rem; } @keyframes enter { from { opacity: var(--enter-opacity, 1); transform: translate( var(--enter-translate-x, 0), var(--enter-translate-y, 0) ) scale(var(--enter-scale, 1)); } to { opacity: 1; transform: translate(0, 0) scale(1); } } @keyframes exit { from { opacity: 1; transform: translate(0, 0) scale(1); } to { opacity: var(--exit-opacity, 1); transform: translate(var(--exit-translate-x, 0), var(--exit-translate-y, 0)) scale(var(--exit-scale, 1)); } } @utility container { margin-inline: auto; padding-inline: 2rem; @media (width >= --theme(--breakpoint-sm)) { max-width: none; } @media (width >= 1400px) { max-width: 1400px; } } @utility prose { /* Heading styles for prose */ & h1 { @apply text-3xl font-bold tracking-tight mt-10 mb-6; } & h2 { @apply text-2xl font-semibold tracking-tight mt-8 mb-4; } & h3 { @apply text-xl font-semibold tracking-tight mt-6 mb-3; } & h4 { @apply text-lg font-medium tracking-tight mt-5 mb-2; } & h5 { @apply text-base font-medium mt-4 mb-2; } & h6 { @apply text-sm font-medium mt-4 mb-2; } } @utility prose-sm { & h1 { @apply text-2xl font-bold tracking-tight mt-8 mb-4; } & h2 { @apply text-xl font-semibold tracking-tight mt-6 mb-3; } & h3 { @apply text-lg font-semibold tracking-tight mt-5 mb-2; } & h4 { @apply text-base font-medium tracking-tight mt-4 mb-2; } & h5 { @apply text-sm font-medium mt-3 mb-1; } & h6 { @apply text-xs font-medium mt-3 mb-1; } } @utility prose-neutral { /* Neutral theme styling for all headings */ & h1 { @apply text-foreground border-b-2 border-border/50 pb-3; } & h2 { @apply text-foreground/90 border-b border-border/40 pb-2; } & h3 { @apply text-foreground/85; } & h4 { @apply text-foreground/80; } & h5 { @apply text-foreground/75; } & h6 { @apply text-foreground/70; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } /* Tailwind v4: Restore pointer cursor for buttons */ button:not(:disabled), [role='button']:not(:disabled) { cursor: pointer; } } ================================================ FILE: app/layout.tsx ================================================ import type { Metadata, Viewport } from 'next' import { Inter as FontSans } from 'next/font/google' import { Analytics } from '@vercel/analytics/next' import { UserProvider } from '@/lib/contexts/user-context' import { createClient } from '@/lib/supabase/server' import { cn } from '@/lib/utils' import { SidebarProvider } from '@/components/ui/sidebar' import { Toaster } from '@/components/ui/sonner' import AppSidebar from '@/components/app-sidebar' import ArtifactRoot from '@/components/artifact/artifact-root' import Header from '@/components/header' import { ThemeProvider } from '@/components/theme-provider' import './globals.css' const fontSans = FontSans({ subsets: ['latin'], variable: '--font-sans' }) const title = 'Morphic' const description = 'A fully open-source AI-powered answer engine with a generative UI.' export const metadata: Metadata = { metadataBase: new URL('https://morphic.sh'), title, description, openGraph: { title, description }, twitter: { title, description, card: 'summary_large_image', creator: '@miiura' } } export const viewport: Viewport = { width: 'device-width', initialScale: 1, minimumScale: 1, maximumScale: 1 } export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { let user = null const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (supabaseUrl && supabaseAnonKey) { const supabase = await createClient() const { data: { user: supabaseUser } } = await supabase.auth.getUser() user = supabaseUser } return ( <html lang="en" suppressHydrationWarning> <body className={cn( 'min-h-screen flex flex-col font-sans antialiased overflow-hidden', fontSans.variable )} > <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > <UserProvider hasUser={!!user}> <SidebarProvider defaultOpen={false}> {user && <AppSidebar />} <div className="flex flex-col flex-1 min-w-0"> <Header user={user} /> <main className="flex flex-1 min-h-0 min-w-0 overflow-hidden"> <ArtifactRoot>{children}</ArtifactRoot> </main> </div> </SidebarProvider> </UserProvider> <Toaster /> <Analytics /> </ThemeProvider> </body> </html> ) } ================================================ FILE: app/page.tsx ================================================ import { getCurrentUserId } from '@/lib/auth/get-current-user' import { Chat } from '@/components/chat' export default async function Page() { const userId = await getCurrentUserId() return <Chat isGuest={!userId} /> } ================================================ FILE: app/search/[id]/page.tsx ================================================ import { notFound, redirect } from 'next/navigation' import { UIMessage } from 'ai' import { loadChat } from '@/lib/actions/chat' import { getCurrentUserId } from '@/lib/auth/get-current-user' import { Chat } from '@/components/chat' export const maxDuration = 60 export async function generateMetadata(props: { params: Promise<{ id: string }> }) { const { id } = await props.params const userId = await getCurrentUserId() const chat = await loadChat(id, userId) if (!chat) { return { title: 'Search' } } return { title: chat.title.toString().slice(0, 50) || 'Search' } } export default async function SearchPage(props: { params: Promise<{ id: string }> }) { const { id } = await props.params const userId = await getCurrentUserId() const chat = await loadChat(id, userId) if (!chat) { notFound() } if (chat.visibility === 'private' && !userId) { redirect('/auth/login') } const messages: UIMessage[] = chat.messages return <Chat id={id} savedMessages={messages} isGuest={!userId} /> } ================================================ FILE: app/search/loading.tsx ================================================ 'use client' import { DefaultSkeleton } from '../../components/default-skeleton' export default function Loading() { return ( <div className="flex min-w-0 flex-1 flex-col items-center"> <div className="w-full max-w-3xl px-4 pt-12"> <DefaultSkeleton /> </div> </div> ) } ================================================ FILE: app/search/page.tsx ================================================ import { redirect } from 'next/navigation' import { getCurrentUserId } from '@/lib/auth/get-current-user' import { generateUUID } from '@/lib/utils' import { Chat } from '@/components/chat' export const maxDuration = 60 export default async function SearchPage(props: { searchParams: Promise<{ q: string }> }) { const { q } = await props.searchParams if (!q) { redirect('/') } const id = generateUUID() const userId = await getCurrentUserId() return <Chat id={id} query={q} isGuest={!userId} /> } ================================================ FILE: components/__tests__/research-process-section.test.tsx ================================================ import React from 'react' import type { ReasoningPart } from '@ai-sdk/provider-utils' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' import type { ToolPart, UIMessage } from '@/lib/types/ai' import { ResearchProcessSection } from '../research-process-section' // Mock the child components vi.mock('../reasoning-section', () => ({ ReasoningSection: ({ content, isOpen, onOpenChange }: any) => ( <div data-testid="reasoning-section"> <button onClick={() => onOpenChange(!isOpen)}> {isOpen ? 'Close' : 'Open'} Reasoning </button> {isOpen && <div>{content.reasoning}</div>} </div> ) })) vi.mock('../tool-section', () => ({ ToolSection: ({ tool, isOpen, onOpenChange }: any) => ( <div data-testid="tool-section"> <button onClick={() => onOpenChange(!isOpen)}> {isOpen ? 'Close' : 'Open'} Tool </button> {isOpen && <div>{tool.type}</div>} </div> ) })) describe('ResearchProcessSection', () => { const mockGetIsOpen = vi.fn() const mockOnOpenChange = vi.fn() const mockOnQuerySelect = vi.fn() const mockAddToolResult = vi.fn() beforeEach(() => { vi.clearAllMocks() mockGetIsOpen.mockReturnValue(false) }) describe('Type Guards', () => { test('correctly identifies reasoning parts', () => { const reasoningPart: ReasoningPart = { type: 'reasoning', text: 'Test reasoning' } const message = { id: 'test-message', role: 'assistant' as const, parts: [reasoningPart] } as unknown as UIMessage render( <ResearchProcessSection message={message} messageId="test-1" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) expect(screen.getByTestId('reasoning-section')).toBeInTheDocument() }) test('correctly identifies tool parts', () => { const toolPart: ToolPart = { type: 'tool-search', toolCallId: 'tool-1', input: {}, state: 'output-available' } const message = { id: 'test-message', role: 'assistant' as const, parts: [toolPart as any] } as UIMessage render( <ResearchProcessSection message={message} messageId="test-2" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) expect(screen.getByTestId('tool-section')).toBeInTheDocument() }) test('filters out empty reasoning parts', () => { const emptyReasoningPart: ReasoningPart = { type: 'reasoning', text: '' } const validReasoningPart: ReasoningPart = { type: 'reasoning', text: 'Valid reasoning' } const message = { id: 'test-message', role: 'assistant' as const, parts: [emptyReasoningPart, validReasoningPart] } render( <ResearchProcessSection message={message} messageId="test-3" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) // Should only render one reasoning section (the valid one) const reasoningSections = screen.getAllByTestId('reasoning-section') expect(reasoningSections).toHaveLength(1) }) }) describe('Segmentation Logic', () => { test('splits parts by text correctly', () => { const parts: any[] = [ { type: 'reasoning', text: 'First reasoning' } as ReasoningPart, { type: 'tool-search', toolCallId: 'tool-1', input: {}, state: 'output-available' } as ToolPart, { type: 'text', text: 'Text separator' }, { type: 'reasoning', text: 'Second reasoning' } as ReasoningPart ] const message: UIMessage = { id: 'test-message', role: 'assistant', parts } render( <ResearchProcessSection message={message} messageId="test-4" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) // Should render 3 sections (2 reasoning + 1 tool, split by text) const allSections = [ ...screen.getAllByTestId('reasoning-section'), ...screen.getAllByTestId('tool-section') ] expect(allSections).toHaveLength(3) }) test('groups consecutive tool parts of same type', () => { const parts: any[] = [ { type: 'tool-search', toolCallId: 'tool-1', input: {}, state: 'output-available' } as ToolPart, { type: 'tool-search', toolCallId: 'tool-2', input: {}, state: 'output-available' } as ToolPart, { type: 'tool-fetch', toolCallId: 'tool-3', input: {}, state: 'output-available' } as ToolPart ] const message: UIMessage = { id: 'test-message', role: 'assistant', parts } render( <ResearchProcessSection message={message} messageId="test-5" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) const toolSections = screen.getAllByTestId('tool-section') expect(toolSections).toHaveLength(3) }) }) describe('Accordion Behavior', () => { test('handles accordion state for grouped sections', () => { const parts: any[] = [ { type: 'reasoning', text: 'First' } as ReasoningPart, { type: 'reasoning', text: 'Second' } as ReasoningPart ] const message: UIMessage = { id: 'test-message', role: 'assistant', parts } const { rerender } = render( <ResearchProcessSection message={message} messageId="test-6" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) const buttons = screen.getAllByRole('button') // Click first button to open fireEvent.click(buttons[0]) // Should call onOpenChange expect(mockOnOpenChange).toHaveBeenCalled() // Update mock to return true for the clicked item mockGetIsOpen.mockImplementation(id => id.includes('reasoning-0-0-0')) rerender( <ResearchProcessSection message={message} messageId="test-6" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) }) test('handles single sections differently from grouped sections', () => { const singlePart = [ { type: 'reasoning', text: 'Single reasoning' } as ReasoningPart ] const message = { id: 'test-message', role: 'assistant' as const, parts: singlePart } render( <ResearchProcessSection message={message} messageId="test-7" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) const button = screen.getByRole('button') fireEvent.click(button) // For single sections, should directly call onOpenChange expect(mockOnOpenChange).toHaveBeenCalledWith( expect.stringContaining('reasoning'), true ) }) }) describe('Subsequent Content Detection', () => { test('detects subsequent content correctly', () => { const parts: any[] = [ { type: 'reasoning', text: 'First' } as ReasoningPart, { type: 'text', text: 'Text' }, { type: 'reasoning', text: 'Second' } as ReasoningPart ] const message: UIMessage = { id: 'test-message', role: 'assistant', parts } render( <ResearchProcessSection message={message} messageId="test-8" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) // The first reasoning should detect subsequent content (the text part) expect(mockGetIsOpen).toHaveBeenCalledWith( expect.stringContaining('reasoning'), 'reasoning', true // hasSubsequentContent should be true ) }) }) describe('Edge Cases', () => { test('returns null for empty segments', () => { const message = { id: 'test-message', role: 'assistant' as const, parts: [] } const { container } = render( <ResearchProcessSection message={message} messageId="test-9" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) expect(container.firstChild).toBeNull() }) test('handles parts override correctly', () => { const message = { id: 'test-message', role: 'assistant' as const, parts: [{ type: 'reasoning', text: 'Original' } as ReasoningPart] } const overrideParts = [ { type: 'reasoning', text: 'Override' } as ReasoningPart ] // Mock getIsOpen to return true so content is visible mockGetIsOpen.mockReturnValue(true) render( <ResearchProcessSection message={message} messageId="test-10" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} parts={overrideParts} /> ) // Should use override parts expect(screen.getByTestId('reasoning-section')).toBeInTheDocument() // The content should show "Override" when open expect(screen.getByText('Override')).toBeInTheDocument() }) test('handles data parts correctly', () => { const parts: any[] = [{ type: 'data-test', data: 'test' }] const message: UIMessage = { id: 'test-message', role: 'assistant', parts } const { container } = render( <ResearchProcessSection message={message} messageId="test-11" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} /> ) // Data parts should render the DataSection component // Check that the component renders (not null) expect(container.firstChild).toBeInTheDocument() }) }) describe('Props Handling', () => { test('passes status prop correctly', () => { const toolPart: ToolPart = { type: 'tool-search', toolCallId: 'tool-1', input: {}, state: 'output-available' } const message = { id: 'test-message', role: 'assistant' as const, parts: [toolPart as any] } as UIMessage render( <ResearchProcessSection message={message} messageId="test-12" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} status="streaming" /> ) expect(screen.getByTestId('tool-section')).toBeInTheDocument() }) test('passes addToolResult prop correctly', () => { const toolPart: ToolPart = { type: 'tool-search', toolCallId: 'tool-1', input: {}, state: 'output-available' } const message = { id: 'test-message', role: 'assistant' as const, parts: [toolPart as any] } as UIMessage render( <ResearchProcessSection message={message} messageId="test-13" getIsOpen={mockGetIsOpen} onOpenChange={mockOnOpenChange} onQuerySelect={mockOnQuerySelect} addToolResult={mockAddToolResult} /> ) expect(screen.getByTestId('tool-section')).toBeInTheDocument() }) }) }) ================================================ FILE: components/action-buttons.tsx ================================================ 'use client' import { useEffect, useRef, useState } from 'react' import { FileText, HelpCircle, LucideIcon, Newspaper, Scale, Search } from 'lucide-react' import { cn } from '@/lib/utils' import { Button } from './ui/button' // Constants for timing delays const FOCUS_OUT_DELAY_MS = 100 // Delay to ensure focus has actually moved interface ActionCategory { icon: LucideIcon label: string key: string } const actionCategories: ActionCategory[] = [ { icon: Search, label: 'Research', key: 'research' }, { icon: Scale, label: 'Compare', key: 'compare' }, { icon: Newspaper, label: 'Latest', key: 'latest' }, { icon: FileText, label: 'Summarize', key: 'summarize' }, { icon: HelpCircle, label: 'Explain', key: 'explain' } ] const promptSamples: Record<string, string[]> = { research: [ 'Why is Nvidia growing so rapidly?', 'Research the latest AI developments', 'What are the key trends in robotics?', 'What are the latest breakthroughs in renewable energy?' ], compare: [ 'Tesla vs BYD vs Toyota comparison', 'Compare Next.js, Remix, and Astro', 'AWS vs GCP vs Azure', 'iPhone vs Android ecosystem comparison' ], latest: [ 'Latest news today', 'What happened in tech this week?', 'Recent breakthroughs in medicine', 'Latest AI model releases' ], summarize: [ 'Summarize: https://arxiv.org/pdf/2504.19678', "Summarize this week's business news", 'Create an executive summary of AI trends', 'Summarize recent climate change research' ], explain: [ 'Explain neural networks simply', 'How does blockchain work?', 'What is quantum entanglement?', 'Explain CRISPR gene editing' ] } interface ActionButtonsProps { onSelectPrompt: (prompt: string) => void onCategoryClick: (category: string) => void inputRef?: React.RefObject<HTMLTextAreaElement> className?: string } export function ActionButtons({ onSelectPrompt, onCategoryClick, inputRef, className }: ActionButtonsProps) { const [activeCategory, setActiveCategory] = useState<string | null>(null) const containerRef = useRef<HTMLDivElement>(null) const handleCategoryClick = (category: ActionCategory) => { setActiveCategory(category.key) onCategoryClick(category.label) } const handlePromptClick = (prompt: string) => { setActiveCategory(null) onSelectPrompt(prompt) } const resetToButtons = () => { setActiveCategory(null) } // Handle Escape key and clicks outside (including focus loss) useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && activeCategory) { resetToButtons() } } const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { if (activeCategory) { // Check if click is not on the input field if (!inputRef?.current?.contains(e.target as Node)) { resetToButtons() } } } } const handleFocusOut = () => { // Check if focus is moving outside both the container and input setTimeout(() => { const activeElement = document.activeElement if ( activeCategory && !containerRef.current?.contains(activeElement) && activeElement !== inputRef?.current ) { resetToButtons() } }, FOCUS_OUT_DELAY_MS) } document.addEventListener('keydown', handleEscape) document.addEventListener('mousedown', handleClickOutside) document.addEventListener('focusout', handleFocusOut) return () => { document.removeEventListener('keydown', handleEscape) document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('focusout', handleFocusOut) } }, [activeCategory, inputRef]) // Calculate max height needed for samples (4 items * ~40px + padding) const containerHeight = 'h-[180px]' return ( <div ref={containerRef} className={cn('relative', containerHeight, className)} > <div className="relative h-full"> {/* Action buttons */} <div className={cn( 'absolute inset-0 flex items-start justify-center pt-2 transition-opacity duration-300', activeCategory ? 'opacity-0 pointer-events-none' : 'opacity-100' )} > <div className="flex flex-wrap justify-center gap-2 px-2"> {actionCategories.map(category => { const Icon = category.icon return ( <Button key={category.key} type="button" variant="outline" size="sm" className={cn( 'flex items-center gap-2 whitespace-nowrap rounded-full', 'text-xs sm:text-sm px-3 sm:px-4' )} onClick={() => handleCategoryClick(category)} > <Icon className="h-3 w-3 sm:h-4 sm:w-4" /> <span>{category.label}</span> </Button> ) })} </div> </div> {/* Prompt samples */} <div className={cn( 'absolute inset-0 py-1 space-y-1 overflow-y-auto transition-opacity duration-300', !activeCategory ? 'opacity-0 pointer-events-none' : 'opacity-100' )} > {activeCategory && promptSamples[activeCategory]?.map((prompt, index) => ( <button key={index} type="button" className={cn( 'w-full text-left px-3 py-2 rounded-md text-sm', 'hover:bg-muted transition-colors', 'flex items-center gap-2 group' )} onClick={() => handlePromptClick(prompt)} > <Search className="h-3 w-3 text-muted-foreground flex-shrink-0 group-hover:text-foreground" /> <span className="line-clamp-1">{prompt}</span> </button> ))} </div> </div> </div> ) } ================================================ FILE: components/answer-section.tsx ================================================ 'use client' import { UseChatHelpers } from '@ai-sdk/react' import { ChatRequestOptions } from 'ai' import type { SearchResultItem } from '@/lib/types' import type { UIDataTypes, UIMessage, UIMessageMetadata, UITools } from '@/lib/types/ai' import { CollapsibleMessage } from './collapsible-message' import { MarkdownMessage } from './message' import { MessageActions } from './message-actions' export type AnswerSectionProps = { content: string isOpen: boolean onOpenChange: (open: boolean) => void chatId?: string showActions?: boolean messageId: string metadata?: UIMessageMetadata status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] reload?: ( messageId: string, options?: ChatRequestOptions ) => Promise<void | string | null | undefined> citationMaps?: Record<string, Record<number, SearchResultItem>> isGuest?: boolean } export function AnswerSection({ content, isOpen, onOpenChange, chatId, showActions = true, // Default to true for backward compatibility messageId, metadata, status, reload, citationMaps, isGuest = false }: AnswerSectionProps) { const enableShare = process.env.NEXT_PUBLIC_SUPABASE_URL !== undefined && !isGuest const handleReload = () => { if (reload) { return reload(messageId) } return Promise.resolve(undefined) } return ( <CollapsibleMessage role="assistant" isCollapsible={false} isOpen={isOpen} onOpenChange={onOpenChange} showBorder={false} showIcon={false} > {content && ( <div className="flex flex-col gap-1"> <MarkdownMessage message={content} citationMaps={citationMaps} /> <MessageActions message={content} // Provide original message; copy path remaps citations messageId={messageId} traceId={metadata?.traceId} feedbackScore={metadata?.feedbackScore} chatId={chatId} enableShare={enableShare} reload={handleReload} status={status} visible={showActions} citationMaps={citationMaps} /> </div> )} </CollapsibleMessage> ) } ================================================ FILE: components/app-sidebar.tsx ================================================ import { Suspense } from 'react' import Link from 'next/link' import { Plus } from 'lucide-react' import { cn } from '@/lib/utils' import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarTrigger } from '@/components/ui/sidebar' import { ChatHistorySection } from './sidebar/chat-history-section' import { ChatHistorySkeleton } from './sidebar/chat-history-skeleton' import { IconLogo } from './ui/icons' export default function AppSidebar() { return ( <Sidebar side="left" variant="sidebar" collapsible="offcanvas"> <SidebarHeader className="flex flex-row justify-between items-center"> <Link href="/" className="flex items-center gap-2 px-2 py-3"> <IconLogo className={cn('size-5')} /> <span className="font-semibold text-sm">Morphic</span> </Link> <SidebarTrigger /> </SidebarHeader> <SidebarContent className="flex flex-col px-2 py-4 h-full"> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton asChild> <Link href="/" className="flex items-center gap-2"> <Plus className="size-4" /> <span>New</span> </Link> </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> <div className="flex-1 overflow-y-auto"> <Suspense fallback={<ChatHistorySkeleton />}> <ChatHistorySection /> </Suspense> </div> </SidebarContent> <SidebarRail /> </Sidebar> ) } ================================================ FILE: components/artifact/artifact-content.tsx ================================================ 'use client' import { Part, ToolPart } from '@/lib/types/ai' import { ReasoningContent } from './reasoning-content' import { TodoInvocationContent } from './todo-invocation-content' import { ToolInvocationContent } from './tool-invocation-content' // Type guard for Todo tool parts function isTodoToolPart(part: Part): part is ToolPart<'todoWrite'> { return part.type === 'tool-todoWrite' } export function ArtifactContent({ part }: { part: Part | null }) { if (!part) return null switch (part.type) { case 'tool-search': case 'tool-fetch': case 'tool-askQuestion': return <ToolInvocationContent part={part} /> case 'tool-todoWrite': if (isTodoToolPart(part)) { return <TodoInvocationContent part={part} /> } return null case 'reasoning': return <ReasoningContent reasoning={part.text} /> default: return ( <div className="p-4">Details for this part type are not available</div> ) } } ================================================ FILE: components/artifact/artifact-context.tsx ================================================ 'use client' import { createContext, ReactNode, useCallback, useContext, useEffect, useReducer } from 'react' import type { Part } from '@/lib/types/ai' import { useSidebar } from '../ui/sidebar' // Animation duration should match CSS transition duration const ANIMATION_DURATION = 300 interface ArtifactState { part: Part | null isOpen: boolean } type ArtifactAction = | { type: 'OPEN'; payload: Part } | { type: 'CLOSE' } | { type: 'CLEAR_CONTENT' } const initialState: ArtifactState = { part: null, isOpen: false } function artifactReducer( state: ArtifactState, action: ArtifactAction ): ArtifactState { switch (action.type) { case 'OPEN': return { part: action.payload, isOpen: true } case 'CLOSE': return { ...state, isOpen: false } case 'CLEAR_CONTENT': return { part: null, isOpen: false } default: return state } } interface ArtifactContextValue { state: ArtifactState open: (part: Part) => void close: () => void } const ArtifactContext = createContext<ArtifactContextValue | undefined>( undefined ) export function ArtifactProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(artifactReducer, initialState) const { setOpen, open: sidebarOpen } = useSidebar() const close = useCallback(() => { dispatch({ type: 'CLOSE' }) // Keep content for animation purposes, clear after transition setTimeout(() => { dispatch({ type: 'CLEAR_CONTENT' }) }, ANIMATION_DURATION) }, []) // Close artifact when sidebar opens useEffect(() => { if (sidebarOpen && state.isOpen) { close() } }, [sidebarOpen, state.isOpen, close]) const open = (part: Part) => { dispatch({ type: 'OPEN', payload: part }) setOpen(false) } return ( <ArtifactContext.Provider value={{ state, open, close }}> {children} </ArtifactContext.Provider> ) } export function useArtifact() { const context = useContext(ArtifactContext) if (context === undefined) { throw new Error('useArtifact must be used within an ArtifactProvider') } return context } ================================================ FILE: components/artifact/artifact-root.tsx ================================================ 'use client' import { ReactNode } from 'react' import { ArtifactProvider } from './artifact-context' import { ChatArtifactContainer } from './chat-artifact-container' export default function ArtifactRoot({ children }: { children: ReactNode }) { return ( <ArtifactProvider> <ChatArtifactContainer>{children}</ChatArtifactContainer> </ArtifactProvider> ) } ================================================ FILE: components/artifact/chat-artifact-container.tsx ================================================ 'use client' import React, { useCallback, useEffect, useRef, useState } from 'react' import { useHasUser } from '@/lib/contexts/user-context' import { cn } from '@/lib/utils' import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar' import { InspectorDrawer } from '@/components/inspector/inspector-drawer' import { InspectorPanel } from '@/components/inspector/inspector-panel' import { useArtifact } from './artifact-context' const DEFAULT_WIDTH = 500 const MIN_WIDTH = 320 const MAX_WIDTH = 800 const CHAT_MIN_WIDTH = 360 const RESIZE_OVERLAY_Z_INDEX = 9999 // Helper function to calculate allowed width bounds function getAllowedWidthBounds(containerWidth: number): { allowedMin: number allowedMax: number } { const available = Math.max(0, containerWidth - CHAT_MIN_WIDTH) const allowedMax = Math.min(MAX_WIDTH, available) // If there's no space available, hide the panel entirely if (allowedMax === 0) { return { allowedMin: 0, allowedMax: 0 } } // Ensure minimum width doesn't exceed available space const allowedMin = Math.min(MIN_WIDTH, allowedMax) return { allowedMin, allowedMax } } export function ChatArtifactContainer({ children }: { children: React.ReactNode }) { const { state } = useArtifact() const containerRef = useRef<HTMLDivElement>(null) const [width, setWidth] = useState(DEFAULT_WIDTH) const [isResizing, setIsResizing] = useState(false) const hasUser = useHasUser() const { open, isMobile: isMobileSidebar } = useSidebar() // Load saved width after hydration useEffect(() => { const savedWidth = localStorage.getItem('artifactPanelWidth') if (savedWidth) { const parsedWidth = parseInt(savedWidth, 10) // Ensure parsedWidth is at least MIN_WIDTH to prevent invalid panel states if ( !isNaN(parsedWidth) && parsedWidth >= MIN_WIDTH && parsedWidth <= MAX_WIDTH ) { // Clamp against available space considering chat minimum width const containerRect = containerRef.current?.getBoundingClientRect() if (containerRect) { const { allowedMin, allowedMax } = getAllowedWidthBounds( containerRect.width ) const clamped = Math.min( Math.max(parsedWidth, allowedMin), allowedMax ) setWidth(clamped) } else { setWidth(parsedWidth) } } } }, []) // Keep width in bounds when container resizes (e.g., window resize) useEffect(() => { const el = containerRef.current if (!el) return const ro = new ResizeObserver(entries => { for (const entry of entries) { const { allowedMin, allowedMax } = getAllowedWidthBounds( entry.contentRect.width ) setWidth(prev => Math.min(Math.max(prev, allowedMin), allowedMax)) } }) ro.observe(el) return () => ro.disconnect() }, []) const startResize = useCallback((e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) }, []) useEffect(() => { if (!isResizing) return const handleMouseMove = (e: MouseEvent) => { const containerRect = containerRef.current?.getBoundingClientRect() if (containerRect) { const newWidth = containerRect.right - e.clientX const { allowedMin, allowedMax } = getAllowedWidthBounds( containerRect.width ) const clampedWidth = Math.min( Math.max(newWidth, allowedMin), allowedMax ) setWidth(clampedWidth) localStorage.setItem('artifactPanelWidth', clampedWidth.toString()) } } const handleMouseUp = () => { setIsResizing(false) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [isResizing]) return ( <div className="flex-1 min-h-0 min-w-0 h-screen flex"> <div className="absolute p-4 z-50 transition-opacity duration-1000"> {hasUser && (!open || isMobileSidebar) && ( <SidebarTrigger className="animate-fade-in" /> )} </div> {/* Desktop: Independent panels like morphic-studio */} <div ref={containerRef} className="hidden md:flex flex-1 min-w-0 overflow-hidden" > {/* Chat Panel - Independent container */} <div className="flex-1 min-w-0 flex flex-col">{children}</div> {/* Resize Handle */} {state.isOpen && state.part && ( <div className={cn( 'w-1 mx-0.5 my-6 hover:bg-border transition-colors duration-200 cursor-col-resize select-none relative', isResizing && 'bg-border/50' )} onMouseDown={startResize} > <div className="absolute inset-0 -left-2 -right-2" /> </div> )} {/* Right Panel - Independent with own animation */} <div className={cn( 'bg-background overflow-hidden', state.isOpen && state.part ? 'opacity-100' : 'w-0 opacity-0', !isResizing && 'transition-all duration-300 ease-out' )} style={{ width: state.isOpen && state.part ? `${width}px` : '0px' }} > <div className="h-full" style={{ width: `${width}px` }}> {state.isOpen && state.part && <InspectorPanel />} </div> </div> </div> {/* Resize overlay to prevent text selection */} {isResizing && ( <div className="fixed inset-0 cursor-col-resize select-none" style={{ zIndex: RESIZE_OVERLAY_Z_INDEX }} /> )} {/* Mobile: full-width chat + drawer */} <div className="md:hidden flex-1 h-full min-w-0"> {children} <InspectorDrawer /> </div> </div> ) } ================================================ FILE: components/artifact/reasoning-content.tsx ================================================ 'use client' import remarkGfm from 'remark-gfm' import { Streamdown } from 'streamdown' import { cn } from '@/lib/utils' export function ReasoningContent({ reasoning }: { reasoning: string }) { return ( <div className="overflow-auto"> <div className={cn('prose-sm dark:prose-invert max-w-none')}> <Streamdown remarkPlugins={[remarkGfm]}>{reasoning}</Streamdown> </div> </div> ) } ================================================ FILE: components/artifact/search-artifact-content.tsx ================================================ 'use client' import type { SearchResults as TypeSearchResults } from '@/lib/types' import type { ToolPart } from '@/lib/types/ai' import { SearchResults } from '@/components/search-results' import { SearchResultsImageSection } from '@/components/search-results-image' import { Section, ToolArgsSection } from '@/components/section' import { createVideoSearchResults, VideoSearchResults } from '@/components/video-search-results' export function SearchArtifactContent({ tool }: { tool: ToolPart<'search'> }) { // Handle streaming output states const output = tool.state === 'output-available' ? tool.output : undefined const searchResults: TypeSearchResults | undefined = output?.state === 'complete' ? output : undefined const query = tool.input?.query const hasResults = searchResults && ((searchResults.results && searchResults.results.length > 0) || (searchResults.videos && searchResults.videos.length > 0) || (searchResults.images && searchResults.images.length > 0)) if (!hasResults) { return <div className="p-4">No search results</div> } return ( <div className="space-y-2"> <div className="pb-2"> <ToolArgsSection tool="search" number={ (searchResults.results?.length || 0) + (searchResults.videos?.length || 0) + (searchResults.images?.length || 0) } >{`${query}`}</ToolArgsSection> </div> {searchResults.images && searchResults.images.length > 0 && ( <SearchResultsImageSection images={searchResults.images} query={query} displayMode="full" /> )} {searchResults.videos && searchResults.videos.length > 0 && ( <Section title="Videos"> <VideoSearchResults results={createVideoSearchResults(searchResults, query)} displayMode="artifact" /> </Section> )} {searchResults.results && searchResults.results.length > 0 && ( <Section title="Sources"> <SearchResults results={searchResults.results} displayMode="list" /> </Section> )} </div> ) } ================================================ FILE: components/artifact/todo-invocation-content.tsx ================================================ 'use client' import type { ToolPart } from '@/lib/types/ai' import TodoListContent from '../todo-list-content' interface TodoInvocationContentProps { part: ToolPart<'todoWrite'> } export function TodoInvocationContent({ part }: TodoInvocationContentProps) { const todos = part.output?.todos || part.input?.todos || [] const completedCount = part.output?.completedCount const totalCount = part.output?.totalCount if (part.state === 'output-error') { return ( <TodoListContent errorText={part.errorText || 'Failed to process todos'} /> ) } const message = part.output && 'message' in part.output ? (part.output.message as string | undefined) : undefined return ( <TodoListContent todos={todos} message={message} completedCount={completedCount} totalCount={totalCount} showSummary={true} itemVariant="plain" /> ) } ================================================ FILE: components/artifact/tool-invocation-content.tsx ================================================ 'use client' import type { ToolPart } from '@/lib/types/ai' import { SearchArtifactContent } from '@/components/artifact/search-artifact-content' export function ToolInvocationContent({ part }: { part: ToolPart }) { switch (part.type) { case 'tool-search': return <SearchArtifactContent tool={part as ToolPart<'search'>} /> default: return <div className="p-4">Details for this tool are not available</div> } } ================================================ FILE: components/attachment-preview.tsx ================================================ 'use client' import React from 'react' interface Attachment { name: string | undefined url: string contentType: string } ;[] interface AttachmentPreviewProps { attachments: Attachment[] } export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({ attachments }) => { if (!attachments?.length) return null return ( <div className="flex flex-wrap gap-4"> {attachments.map((att, index) => { const isImage = att.contentType.startsWith('image/') const isPdf = att.contentType === 'application/pdf' return ( <div key={index} className="max-w-xs break-words"> {isImage ? ( // eslint-disable-next-line @next/next/no-img-element <img src={att.url} alt={att.name} className="rounded-md border max-h-16 object-contain" /> ) : isPdf ? ( <a href={att.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline" > 📄 {att.name} </a> ) : ( <a href={att.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline" > 📎 {att.name} </a> )} </div> ) })} </div> ) } ================================================ FILE: components/auth-modal.tsx ================================================ 'use client' import Link from 'next/link' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { IconLogo } from '@/components/ui/icons' interface AuthModalProps { open: boolean onOpenChange: (open: boolean) => void } export function AuthModal({ open, onOpenChange }: AuthModalProps) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-md"> <DialogHeader className="items-center text-center"> <div className="mx-auto mb-6 flex size-20 items-center justify-center rounded-full bg-muted"> <IconLogo className="size-14" /> </div> <DialogTitle className="text-xl font-semibold"> Continue with Morphic </DialogTitle> <DialogDescription className="text-muted-foreground"> To use Morphic, sign in to your account or create a new one. </DialogDescription> </DialogHeader> <div className="mt-6 space-y-3"> <Button asChild className="w-full" size="lg"> <Link href="/auth/sign-up">Sign Up</Link> </Button> <Button asChild variant="outline" className="w-full" size="lg"> <Link href="/auth/login">Sign In</Link> </Button> </div> </DialogContent> </Dialog> ) } ================================================ FILE: components/chat-error.tsx ================================================ import { AlertCircle } from 'lucide-react' import { Card } from '@/components/ui/card' interface ChatErrorProps { error: Error | string | null | undefined } export function ChatError({ error }: ChatErrorProps) { if (!error) return null let errorMessage = error instanceof Error ? error.message : error // Try to parse JSON error response and extract user-friendly message try { const jsonMatch = errorMessage?.match(/\{.*\}/) if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]) if (parsed.error) { errorMessage = parsed.error } } } catch { // If parsing fails, use the original error message } return ( <Card className="border-destructive bg-destructive/10 p-4"> <div className="flex items-center gap-3"> <AlertCircle className="size-5 text-destructive shrink-0" /> <p className="text-sm text-destructive">{errorMessage}</p> </div> </Card> ) } ================================================ FILE: components/chat-messages.tsx ================================================ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { UseChatHelpers } from '@ai-sdk/react' import { useMediaQuery } from '@/lib/hooks/use-media-query' import type { UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import { cn } from '@/lib/utils' import { extractCitationMapsFromMessages } from '@/lib/utils/citation' import { AnimatedLogo } from './ui/animated-logo' import { ChatError } from './chat-error' import { RenderMessage } from './render-message' // Import section structure interface interface ChatSection { id: string userMessage: UIMessage assistantMessages: UIMessage[] } interface ChatMessagesProps { sections: ChatSection[] // Changed from messages to sections onQuerySelect: (query: string) => void status: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] chatId?: string isGuest?: boolean addToolResult?: (params: { toolCallId: string; result: any }) => void /** Ref for the scroll container */ scrollContainerRef: React.RefObject<HTMLDivElement> onUpdateMessage?: (messageId: string, newContent: string) => Promise<void> reload?: (messageId: string) => Promise<void | string | null | undefined> error?: Error | string | null | undefined } export function ChatMessages({ sections, onQuerySelect, status, chatId, isGuest = false, addToolResult, scrollContainerRef, onUpdateMessage, reload, error }: ChatMessagesProps) { // Track user-modified states (when user explicitly opens/closes) const [userModifiedStates, setUserModifiedStates] = useState< Record<string, boolean> >({}) // Cache tool counts for performance optimization const toolCountCacheRef = useRef<Map<string, number>>(new Map()) const isLoading = status === 'submitted' || status === 'streaming' const isMobile = useMediaQuery('(max-width: 767px)') // Tool types definition - moved outside function for performance const toolTypes = [ 'tool-search', 'tool-fetch', 'tool-askQuestion', 'tool-relatedQuestions' ] // Clear cache during streaming to ensure accurate tool counts useEffect(() => { if (isLoading) { // Clear cache for all messages during streaming toolCountCacheRef.current.clear() } }, [isLoading]) // Calculate the offset height based on device type // Note: pt-14 (56px) on scroll-container must be included in desktop offset const offsetHeight = isMobile ? 208 // Mobile: larger offset for mobile header/input : 196 // Desktop: smaller offset (140px) + pt-14 (56px) // Extract citation maps from all messages in all sections const allCitationMaps = useMemo(() => { const allMessages: UIMessage[] = [] sections.forEach(section => { allMessages.push(section.userMessage) allMessages.push(...section.assistantMessages) }) return extractCitationMapsFromMessages(allMessages) }, [sections]) if (!sections.length) return null // Check if loading indicator should be shown const showLoading = status === 'submitted' || status === 'streaming' // Helper function to get tool count with caching const getToolCount = (message?: UIMessage): number => { if (!message || !message.id) return 0 // During streaming, always recalculate if (isLoading) { const count = message.parts?.filter(part => toolTypes.includes(part.type)).length || 0 return count } // Check cache first when not streaming const cached = toolCountCacheRef.current.get(message.id) if (cached !== undefined) { return cached } // Calculate and cache const count = message.parts?.filter(part => toolTypes.includes(part.type)).length || 0 toolCountCacheRef.current.set(message.id, count) return count } const getIsOpen = ( id: string, partType?: string, hasNextPart?: boolean, message?: UIMessage ) => { // If user has explicitly modified this state, use that if (userModifiedStates.hasOwnProperty(id)) { return userModifiedStates[id] } // For tool types, check if there are multiple tools if (partType && toolTypes.includes(partType)) { const toolCount = getToolCount(message) // If multiple tools exist, default to closed if (toolCount > 1) { return false } // Single tool results stay open even if more content follows return true } // For tool-invocations, default to open if (partType === 'tool-invocation') { return true } // For reasoning, auto-collapse if there's a next part in the same message if (partType === 'reasoning') { return !hasNextPart } // For other types (like text), default to open return true } const handleOpenChange = (id: string, open: boolean) => { setUserModifiedStates(prev => ({ ...prev, [id]: open })) } return ( <div id="scroll-container" ref={scrollContainerRef} role="list" aria-roledescription="chat messages" className={cn( 'relative size-full pt-14', sections.length > 0 ? 'flex-1 overflow-y-auto' : '' )} > <div className="relative mx-auto w-full max-w-full md:max-w-3xl px-4"> {sections.map((section, sectionIndex) => ( <div key={section.id} id={`section-${section.id}`} className="chat-section pb-14" style={ sectionIndex === sections.length - 1 ? { minHeight: `calc(100dvh - ${offsetHeight}px)` } : {} } > {/* User message */} <div className="flex flex-col gap-4 mb-4"> <RenderMessage message={section.userMessage} messageId={section.userMessage.id} getIsOpen={(id, partType, hasNextPart) => getIsOpen(id, partType, hasNextPart, section.userMessage) } onOpenChange={handleOpenChange} onQuerySelect={onQuerySelect} chatId={chatId} isGuest={isGuest} status={status} addToolResult={addToolResult} onUpdateMessage={onUpdateMessage} reload={reload} citationMaps={allCitationMaps} /> </div> {/* Assistant messages */} {section.assistantMessages.map((assistantMessage, messageIndex) => { // Check if this is the latest assistant message in the latest section const isLatestMessage = sectionIndex === sections.length - 1 && messageIndex === section.assistantMessages.length - 1 return ( <div key={assistantMessage.id} className="flex flex-col gap-4"> <RenderMessage message={assistantMessage} messageId={assistantMessage.id} getIsOpen={(id, partType, hasNextPart) => getIsOpen(id, partType, hasNextPart, assistantMessage) } onOpenChange={handleOpenChange} onQuerySelect={onQuerySelect} chatId={chatId} isGuest={isGuest} status={status} addToolResult={addToolResult} onUpdateMessage={onUpdateMessage} reload={reload} isLatestMessage={isLatestMessage} citationMaps={allCitationMaps} /> </div> ) })} {/* Show loading after assistant messages */} {showLoading && sectionIndex === sections.length - 1 && ( <div className="flex justify-start py-4"> <AnimatedLogo className="h-10 w-10" /> </div> )} {sectionIndex === sections.length - 1 && ( <ChatError error={error} /> )} </div> ))} </div> </div> ) } ================================================ FILE: components/chat-panel.tsx ================================================ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' import Textarea from 'react-textarea-autosize' import { useRouter } from 'next/navigation' import { UseChatHelpers } from '@ai-sdk/react' import { ArrowUp, ChevronDown, MessageCirclePlus, Square } from 'lucide-react' import { toast } from 'sonner' import { UploadedFile } from '@/lib/types' import type { UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import { cn } from '@/lib/utils' import { useArtifact } from './artifact/artifact-context' import { Button } from './ui/button' import { IconBlinkingLogo } from './ui/icons' import { ActionButtons } from './action-buttons' import { FileUploadButton } from './file-upload-button' import { ModelTypeSelector } from './model-type-selector' import { SearchModeSelector } from './search-mode-selector' import { UploadedFileList } from './uploaded-file-list' // Constants for timing delays const INPUT_UPDATE_DELAY_MS = 10 // Delay to ensure input value is updated before form submission interface ChatPanelProps { chatId: string input: string handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void status: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] messages: UIMessage[] setMessages: (messages: UIMessage[]) => void query?: string stop: () => void append: (message: any) => void /** Whether to show the scroll to bottom button */ showScrollToBottomButton: boolean /** Reference to the scroll container */ scrollContainerRef: React.RefObject<HTMLDivElement> uploadedFiles: UploadedFile[] setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>> /** Callback to reset chatId when starting a new chat */ onNewChat?: () => void /** Whether the current session is guest */ isGuest?: boolean } export function ChatPanel({ chatId, input, handleInputChange, handleSubmit, status, messages, setMessages, query, stop, append, showScrollToBottomButton, uploadedFiles, setUploadedFiles, scrollContainerRef, onNewChat, isGuest = false }: ChatPanelProps) { const router = useRouter() const inputRef = useRef<HTMLTextAreaElement>(null) const isFirstRender = useRef(true) const [isComposing, setIsComposing] = useState(false) // Composition state const [enterDisabled, setEnterDisabled] = useState(false) // Disable Enter after composition ends const [isInputFocused, setIsInputFocused] = useState(false) // Track input focus const { close: closeArtifact } = useArtifact() const isLoading = status === 'submitted' || status === 'streaming' const handleCompositionStart = () => setIsComposing(true) const handleCompositionEnd = () => { setIsComposing(false) setEnterDisabled(true) setTimeout(() => { setEnterDisabled(false) }, 300) } const handleNewChat = () => { setMessages([]) closeArtifact() // Reset focus state when clearing chat setIsInputFocused(false) inputRef.current?.blur() // Reset chatId in parent component onNewChat?.() router.push('/') } const isToolInvocationInProgress = () => { if (!messages.length) return false const lastMessage = messages[messages.length - 1] if (lastMessage.role !== 'assistant' || !lastMessage.parts) return false const parts = lastMessage.parts const lastPart = parts[parts.length - 1] return ( (lastPart?.type === 'tool-search' || lastPart?.type === 'tool-fetch' || lastPart?.type === 'tool-askQuestion') && ((lastPart as any)?.state === 'input-streaming' || (lastPart as any)?.state === 'input-available') ) } // if query is not empty, submit the query useEffect(() => { if (isFirstRender.current && query && query.trim().length > 0) { append({ role: 'user', content: query }) isFirstRender.current = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]) const handleFileRemove = useCallback( (index: number) => { setUploadedFiles(prev => prev.filter((_, i) => i !== index)) }, [setUploadedFiles] ) // Scroll to the bottom of the container const handleScrollToBottom = () => { const scrollContainer = scrollContainerRef.current if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' }) } } return ( <div className={cn( 'w-full bg-background group/form-container shrink-0', messages.length > 0 ? 'sticky bottom-0 px-2 pb-4' : 'px-6' )} > {messages.length === 0 && ( <div className="mb-10 flex flex-col items-center gap-4"> <IconBlinkingLogo className="size-12" /> <h1 className="text-2xl font-medium text-foreground"> What would you like to know? </h1> </div> )} {uploadedFiles.length > 0 && ( <UploadedFileList files={uploadedFiles} onRemove={handleFileRemove} /> )} <form onSubmit={e => { handleSubmit(e) // Reset focus state after submission setIsInputFocused(false) inputRef.current?.blur() }} className={cn('max-w-full md:max-w-3xl w-full mx-auto relative')} > {/* Scroll to bottom button - only shown when showScrollToBottomButton is true */} {showScrollToBottomButton && messages.length > 0 && ( <Button type="button" variant="outline" size="icon" className="absolute -top-10 right-4 z-20 size-8 rounded-full shadow-md" onClick={handleScrollToBottom} title="Scroll to bottom" > <ChevronDown size={16} /> </Button> )} <div className={cn( 'relative flex flex-col w-full gap-2 bg-muted rounded-3xl border border-input transition-shadow', isInputFocused && 'ring-1 ring-ring/20 ring-offset-1 ring-offset-background/50' )} > <Textarea ref={inputRef} name="input" rows={2} maxRows={5} tabIndex={0} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} placeholder="Ask anything..." spellCheck={false} value={input} disabled={isLoading || isToolInvocationInProgress()} className="resize-none w-full min-h-12 bg-transparent border-0 p-4 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50" onChange={handleInputChange} onKeyDown={e => { if ( e.key === 'Enter' && !e.shiftKey && !isComposing && !enterDisabled ) { if (input.trim().length === 0) { e.preventDefault() return } e.preventDefault() const textarea = e.target as HTMLTextAreaElement textarea.form?.requestSubmit() // Reset focus state after Enter key submission setIsInputFocused(false) textarea.blur() } }} /> {/* Bottom menu area */} <div className="flex items-center justify-between p-3"> <div className="flex items-center gap-2"> {!isGuest && ( <FileUploadButton onFileSelect={async files => { const newFiles: UploadedFile[] = files.map(file => ({ file, status: 'uploading' })) setUploadedFiles(prev => [...prev, ...newFiles]) await Promise.all( newFiles.map(async uf => { const formData = new FormData() formData.append('file', uf.file) formData.append('chatId', chatId) try { const res = await fetch('/api/upload', { method: 'POST', body: formData }) if (!res.ok) { throw new Error('Upload failed') } const { file: uploaded } = await res.json() setUploadedFiles(prev => prev.map(f => f.file === uf.file ? { ...f, status: 'uploaded', url: uploaded.url, name: uploaded.filename, key: uploaded.key } : f ) ) } catch (e) { toast.error(`Failed to upload ${uf.file.name}`) setUploadedFiles(prev => prev.map(f => f.file === uf.file ? { ...f, status: 'error' } : f ) ) } }) ) }} /> )} <SearchModeSelector /> </div> <div className="flex items-center gap-2"> {messages.length > 0 && ( <Button variant="outline" size="icon" onClick={handleNewChat} className="shrink-0 rounded-full group" type="button" disabled={isLoading} > <MessageCirclePlus className="size-4 group-hover:rotate-12 transition-all" /> </Button> )} {process.env.NEXT_PUBLIC_MORPHIC_CLOUD_DEPLOYMENT !== 'true' && ( <ModelTypeSelector disabled={isGuest} /> )} <Button type={isLoading ? 'button' : 'submit'} size={'icon'} className={cn(isLoading && 'animate-pulse', 'rounded-full')} disabled={input.length === 0 && !isLoading} onClick={isLoading ? stop : undefined} > {isLoading ? <Square size={20} /> : <ArrowUp size={20} />} </Button> </div> </div> </div> {/* Action buttons for prompt suggestions */} {messages.length === 0 && ( <ActionButtons onSelectPrompt={message => { // Set the input value and submit handleInputChange({ target: { value: message } } as React.ChangeEvent<HTMLTextAreaElement>) // Submit the form after a small delay to ensure the input is updated setTimeout(() => { inputRef.current?.form?.requestSubmit() // Reset focus state after action button submission setIsInputFocused(false) inputRef.current?.blur() }, INPUT_UPDATE_DELAY_MS) }} onCategoryClick={category => { // Set the category in the input handleInputChange({ target: { value: category } } as React.ChangeEvent<HTMLTextAreaElement>) // Focus the input inputRef.current?.focus() }} inputRef={inputRef} className="mt-2" /> )} </form> </div> ) } ================================================ FILE: components/chat-share.tsx ================================================ 'use client' import { useState, useTransition } from 'react' import { Share } from 'lucide-react' import { toast } from 'sonner' import { shareChat } from '@/lib/actions/chat' import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' import { cn } from '@/lib/utils' import { Button } from './ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog' import { Spinner } from './ui/spinner' interface ChatShareProps { chatId: string className?: string } export function ChatShare({ chatId, className }: ChatShareProps) { const [open, setOpen] = useState(false) const [pending, startTransition] = useTransition() const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) const [shareUrl, setShareUrl] = useState('') const handleShare = async () => { startTransition(() => { setOpen(true) }) const sharedChatObject = await shareChat(chatId) if (!sharedChatObject) { toast.error( 'Failed to make chat public. You may need to be logged in or own the chat.' ) return } const url = new URL( `/search/${sharedChatObject.id}`, window.location.origin ) setShareUrl(url.toString()) } const handleCopy = () => { if (shareUrl) { copyToClipboard(shareUrl) toast.success('Link copied to clipboard') setOpen(false) } else { toast.error('No link to copy') } } return ( <div className={className}> <Dialog open={open} onOpenChange={open => setOpen(open)} aria-labelledby="share-dialog-title" aria-describedby="share-dialog-description" > <DialogTrigger asChild> <Button className={cn('rounded-full')} size="icon" variant={'ghost'} onClick={() => setOpen(true)} > <Share size={14} /> </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Share Chat</DialogTitle> <DialogDescription> Anyone with the link will be able to view this chat if it's public. </DialogDescription> </DialogHeader> <DialogFooter className="items-center"> {!shareUrl && ( <Button onClick={handleShare} disabled={pending} size="sm"> {pending ? <Spinner /> : 'Get link'} </Button> )} {shareUrl && ( <Button onClick={handleCopy} disabled={pending} size="sm"> {'Copy link'} </Button> )} </DialogFooter> </DialogContent> </Dialog> </div> ) } ================================================ FILE: components/chat.tsx ================================================ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { useChat } from '@ai-sdk/react' import { DefaultChatTransport } from 'ai' import { toast } from 'sonner' import { generateId } from '@/lib/db/schema' import { UploadedFile } from '@/lib/types' import type { UIMessage } from '@/lib/types/ai' import { isDynamicToolPart, isToolCallPart, isToolTypePart } from '@/lib/types/dynamic-tools' import { cn } from '@/lib/utils' import { useFileDropzone } from '@/hooks/use-file-dropzone' import { ChatMessages } from './chat-messages' import { ChatPanel } from './chat-panel' import { DragOverlay } from './drag-overlay' import { ErrorModal } from './error-modal' // Define section structure interface ChatSection { id: string // User message ID userMessage: UIMessage assistantMessages: UIMessage[] } export function Chat({ id: providedId, savedMessages = [], query, isGuest = false }: { id?: string savedMessages?: UIMessage[] query?: string isGuest?: boolean }) { const router = useRouter() // Generate a stable chatId on the client side // - If providedId exists (e.g., /search/[id]), use it for existing chats // - Otherwise, generate a new ID (e.g., / homepage for new chats) const [chatId, setChatId] = useState(() => providedId || generateId()) // Callback to reset chat state when user clicks "New" button const handleNewChat = () => { const newId = generateId() setChatId(newId) // Clear other chat-related state that persists due to Next.js 16 component caching setInput('') setUploadedFiles([]) setErrorModal({ open: false, type: 'general', message: '' }) } const scrollContainerRef = useRef<HTMLDivElement>(null) const [isAtBottom, setIsAtBottom] = useState(true) const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]) const [input, setInput] = useState('') const [errorModal, setErrorModal] = useState<{ open: boolean type: 'rate-limit' | 'auth' | 'forbidden' | 'general' message: string details?: string }>({ open: false, type: 'general', message: '' }) const { messages, status, setMessages, stop, sendMessage, regenerate, addToolResult, error } = useChat({ id: chatId, // use the client-generated or provided chatId transport: new DefaultChatTransport({ api: '/api/chat', prepareSendMessagesRequest: ({ messages, trigger, messageId }) => { // Simplify by passing AI SDK's default trigger values directly const lastMessage = messages[messages.length - 1] const messageToRegenerate = trigger === 'regenerate-message' ? messages.find(m => m.id === messageId) : undefined return { body: { trigger, // Use AI SDK's default trigger value directly chatId: chatId, messageId, ...(isGuest ? { messages } : {}), message: trigger === 'regenerate-message' && messageToRegenerate?.role === 'user' ? messageToRegenerate : trigger === 'submit-message' ? lastMessage : undefined, isNewChat: trigger === 'submit-message' && messages.length === 1 && savedMessages.length === 0 } } } }), messages: savedMessages, onFinish: () => { window.dispatchEvent(new CustomEvent('chat-history-updated')) }, onError: error => { // Handle rate limiting errors from Vercel WAF // Check for status codes in error message or specific rate limit indicators const errorMessage = error.message?.toLowerCase() || '' const isRateLimit = error.message?.includes('429') || errorMessage.includes('rate limit') || errorMessage.includes('too many requests') || errorMessage.includes('daily limit') // Check for authentication errors const isAuthError = error.message?.includes('401') || errorMessage.includes('unauthorized') || errorMessage.includes('authentication required') || errorMessage.includes('sign in to continue') if (isRateLimit) { // Try to parse JSON error response for quality mode rate limit let parsedError: { error?: string resetAt?: number remaining?: number } = {} try { // Extract JSON from error message if it exists const jsonMatch = error.message?.match(/\{.*\}/) if (jsonMatch) { parsedError = JSON.parse(jsonMatch[0]) } } catch { // Ignore parse errors } // Use parsed error message or fallback const userMessage = parsedError.error || 'You have reached your daily limit for quality mode chat requests.' setErrorModal({ open: true, type: 'rate-limit', message: userMessage, details: undefined }) } else if (isAuthError) { setErrorModal({ open: true, type: 'auth', message: error.message }) } else if ( error.message?.includes('403') || errorMessage.includes('forbidden') ) { setErrorModal({ open: true, type: 'forbidden', message: error.message }) } else { // For general errors, still use toast for less intrusive notification toast.error(`Error in chat: ${error.message}`) } }, experimental_throttle: 100, generateId }) const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setInput(e.target.value) } // Convert messages array to sections array const sections = useMemo<ChatSection[]>(() => { const result: ChatSection[] = [] let currentSection: ChatSection | null = null for (const message of messages) { if (message.role === 'user') { // Start a new section when a user message is found if (currentSection) { result.push(currentSection) } currentSection = { id: message.id, userMessage: message, assistantMessages: [] } } else if (currentSection && message.role === 'assistant') { // Add assistant message to the current section currentSection.assistantMessages.push(message) } // Ignore other role types like 'system' for now } // Add the last section if exists if (currentSection) { result.push(currentSection) } return result }, [messages]) // Dispatch custom event when messages change useEffect(() => { window.dispatchEvent( new CustomEvent('messages-changed', { detail: { hasMessages: messages.length > 0 } }) ) }, [messages.length]) // Detect if scroll container is at the bottom useEffect(() => { const container = scrollContainerRef.current if (!container) return const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container const threshold = 50 // threshold in pixels if (scrollHeight - scrollTop - clientHeight < threshold) { setIsAtBottom(true) } else { setIsAtBottom(false) } } container.addEventListener('scroll', handleScroll, { passive: true }) handleScroll() // Set initial state return () => container.removeEventListener('scroll', handleScroll) }, [messages.length]) // Check scroll position when messages change (during generation) useEffect(() => { const container = scrollContainerRef.current if (!container) return const { scrollTop, scrollHeight, clientHeight } = container const threshold = 50 if (scrollHeight - scrollTop - clientHeight < threshold) { setIsAtBottom(true) } else { setIsAtBottom(false) } }, [messages]) // Scroll to the section when a new user message is sent useEffect(() => { // Only scroll if this chat is currently visible in the URL const isCurrentChat = window.location.pathname === `/search/${chatId}` || (window.location.pathname === '/' && sections.length > 0) if (isCurrentChat && sections.length > 0) { const lastMessage = messages[messages.length - 1] if (lastMessage && lastMessage.role === 'user') { // If the last message is from user, find the corresponding section const sectionId = lastMessage.id requestAnimationFrame(() => { const sectionElement = document.getElementById(`section-${sectionId}`) sectionElement?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }) } } }, [sections, messages, chatId]) const onQuerySelect = (query: string) => { sendMessage({ role: 'user', parts: [{ type: 'text', text: query }] }) } const handleUpdateAndReloadMessage = async ( editedMessageId: string, newContentText: string ) => { if (!chatId) { toast.error('Chat ID is missing.') console.error('handleUpdateAndReloadMessage: chatId is undefined.') return } try { // Update the message locally with the same ID setMessages(prevMessages => { const messageIndex = prevMessages.findIndex( m => m.id === editedMessageId ) if (messageIndex === -1) return prevMessages const updatedMessages = [...prevMessages] updatedMessages[messageIndex] = { ...updatedMessages[messageIndex], parts: [{ type: 'text', text: newContentText }] } return updatedMessages }) // Regenerate from this message await regenerate({ messageId: editedMessageId }) } catch (error) { console.error('Error during message edit and reload process:', error) toast.error( `Error processing edited message: ${(error as Error).message}` ) } } const handleReloadFrom = async (reloadFromFollowerMessageId: string) => { if (!chatId) { toast.error('Chat ID is missing for reload.') return } try { // Use the SDK's regenerate function with the specific messageId await regenerate({ messageId: reloadFromFollowerMessageId }) } catch (error) { console.error( `Error during reload from message ${reloadFromFollowerMessageId}:`, error ) toast.error(`Failed to reload conversation: ${(error as Error).message}`) } } const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() const uploaded = uploadedFiles.filter(f => f.status === 'uploaded') if (input.trim() || uploaded.length > 0) { const parts: any[] = [] if (input.trim()) { parts.push({ type: 'text', text: input }) } uploaded.forEach(f => { parts.push({ type: 'file', url: f.url!, filename: f.name!, mediaType: f.file.type }) }) sendMessage({ role: 'user', parts }) setInput('') setUploadedFiles([]) // Push URL state immediately after sending message (for new chats) // Check if we're on the root path (new chat) if (!isGuest && window.location.pathname === '/') { window.history.pushState({}, '', `/search/${chatId}`) } } } const { isDragging, handleDragOver, handleDragLeave, handleDrop } = useFileDropzone({ uploadedFiles, setUploadedFiles, chatId: chatId }) const guestDragHandlers = { isDragging: false, handleDragOver: (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() }, handleDragLeave: (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() }, handleDrop: (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() } } const dragHandlers = isGuest ? guestDragHandlers : { isDragging, handleDragOver, handleDragLeave, handleDrop } return ( <div className={cn( 'relative flex h-full min-w-0 flex-1 flex-col', messages.length === 0 ? 'items-center justify-center' : '' )} data-testid="full-chat" onDragOver={dragHandlers.handleDragOver} onDragLeave={dragHandlers.handleDragLeave} onDrop={dragHandlers.handleDrop} > <ChatMessages sections={sections} onQuerySelect={onQuerySelect} status={status} chatId={chatId} isGuest={isGuest} addToolResult={({ toolCallId, result }: { toolCallId: string result: any }) => { // Find the tool name from the message parts let toolName = 'unknown' // Optimize by breaking early once found outerLoop: for (const message of messages) { if (!message.parts) continue for (const part of message.parts) { if (isToolCallPart(part) && part.toolCallId === toolCallId) { toolName = part.toolName break outerLoop } else if ( isToolTypePart(part) && part.toolCallId === toolCallId ) { toolName = part.type.substring(5) // Remove 'tool-' prefix break outerLoop } else if ( isDynamicToolPart(part) && part.toolCallId === toolCallId ) { toolName = part.toolName break outerLoop } } } addToolResult({ tool: toolName, toolCallId, output: result }) }} scrollContainerRef={scrollContainerRef} onUpdateMessage={handleUpdateAndReloadMessage} reload={handleReloadFrom} error={error} /> <ChatPanel chatId={chatId} input={input} handleInputChange={handleInputChange} handleSubmit={onSubmit} status={status} messages={messages} setMessages={setMessages} stop={stop} query={query} append={(message: any) => { sendMessage(message) }} showScrollToBottomButton={!isAtBottom} uploadedFiles={uploadedFiles} setUploadedFiles={setUploadedFiles} scrollContainerRef={scrollContainerRef} onNewChat={handleNewChat} isGuest={isGuest} /> <DragOverlay visible={dragHandlers.isDragging} /> <ErrorModal open={errorModal.open} onOpenChange={open => setErrorModal(prev => ({ ...prev, open }))} error={errorModal} onRetry={ errorModal.type !== 'rate-limit' ? () => { // Retry the last message if not rate limited if (messages.length > 0) { const lastUserMessage = messages .filter(m => m.role === 'user') .pop() if (lastUserMessage) { sendMessage(lastUserMessage) } } } : undefined } onAuthClose={() => { // Clear messages and navigate to root setMessages([]) router.push('/') }} /> </div> ) } ================================================ FILE: components/citation-context.tsx ================================================ 'use client' import { createContext, ReactNode, useContext } from 'react' import type { SearchResultItem } from '@/lib/types' interface CitationContextValue { citationMaps?: Record<string, Record<number, SearchResultItem>> } const CitationContext = createContext<CitationContextValue | undefined>( undefined ) export function CitationProvider({ children, citationMaps }: { children: ReactNode citationMaps?: Record<string, Record<number, SearchResultItem>> }) { return ( <CitationContext.Provider value={{ citationMaps }}> {children} </CitationContext.Provider> ) } export function useCitation() { const context = useContext(CitationContext) return context || { citationMaps: undefined } } ================================================ FILE: components/citation-link.tsx ================================================ 'use client' import { memo, useState } from 'react' import Link from 'next/link' import type { SearchResultItem } from '@/lib/types' import { cn } from '@/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' interface CitationLinkProps { href: string children: React.ReactNode className?: string citationData?: SearchResultItem } // Helper function to safely extract hostname from URL const getHostname = (url: string): string => { try { return new URL(url).hostname } catch { return 'unknown' } } export const CitationLink = memo(function CitationLink({ href, children, className, citationData }: CitationLinkProps) { const [open, setOpen] = useState(false) const childrenText = children?.toString() || '' // Match domain names (alphanumeric and hyphens) or numbers for backward compatibility const isCitation = /^[\w-]+$/.test(childrenText) const linkClasses = cn( isCitation ? 'text-[10px] bg-muted/50 text-muted-foreground/60 rounded-full h-4 px-1.5 inline-flex items-center justify-center hover:bg-primary hover:text-primary-foreground duration-200 no-underline -translate-y-0.5 whitespace-nowrap' : 'hover:underline inline-flex items-center gap-1.5', className ) // If no citation data, render as simple link if (!citationData) { return ( <a href={href} target="_blank" rel="noopener noreferrer" className={linkClasses} > {children} </a> ) } // For citations with data, show popover on hover if (isCitation && citationData) { return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <a href={href} target="_blank" rel="noopener noreferrer" className={linkClasses} onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)} > {children} </a> </PopoverTrigger> <PopoverContent className="w-80 p-0 z-50 shadow-xs" side="bottom" align="start" sideOffset={4} onPointerDownOutside={e => e.preventDefault()} > {citationData ? ( <Link href={citationData.url} target="_blank" rel="noopener noreferrer" className="block p-3 hover:bg-accent/50 transition-colors" > <div className="space-y-2"> <div className="flex items-center gap-2"> <Avatar className="h-4 w-4 shrink-0"> <AvatarImage src={`https://www.google.com/s2/favicons?domain=${getHostname( citationData.url )}`} alt={getHostname(citationData.url)} /> <AvatarFallback className="text-xs"> {getHostname(citationData.url)[0]?.toUpperCase() || '?'} </AvatarFallback> </Avatar> <span className="text-xs text-muted-foreground truncate"> {getHostname(citationData.url)} </span> </div> <p className="text-sm font-medium line-clamp-1"> {citationData.title} </p> <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed"> {citationData.content} </p> </div> </Link> ) : null} </PopoverContent> </Popover> ) } // For non-numbered citations, render as regular link return ( <a href={href} target="_blank" rel="noopener noreferrer" className={linkClasses} > {children} </a> ) }) ================================================ FILE: components/collapsible-message.tsx ================================================ import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible' import { IconLogo } from './ui/icons' import { Separator } from './ui/separator' import { CurrentUserAvatar } from './current-user-avatar' interface CollapsibleMessageProps { children: React.ReactNode role: 'user' | 'assistant' isCollapsible?: boolean isOpen?: boolean header?: React.ReactNode onOpenChange?: (open: boolean) => void showBorder?: boolean showIcon?: boolean variant?: 'default' | 'minimal' | 'process' | 'process-sub' showSeparator?: boolean renderLeft?: React.ReactNode chevronSize?: 'sm' | 'md' headerClickBehavior?: 'toggle' | 'inspect' | 'split' } export function CollapsibleMessage({ children, role, isCollapsible = false, isOpen = true, header, onOpenChange, showBorder = true, showIcon = true, variant = 'default', showSeparator = true, renderLeft, chevronSize = 'md', headerClickBehavior = 'toggle' }: CollapsibleMessageProps) { const content = children return ( <div className="flex"> {renderLeft ? ( renderLeft ) : showIcon ? ( <div className="relative flex flex-col items-center"> <div className="w-5"> {role === 'assistant' ? ( <IconLogo className="size-5" /> ) : ( <CurrentUserAvatar /> )} </div> </div> ) : null} {isCollapsible ? ( <div className={cn( 'flex-1 overflow-hidden min-w-0', variant === 'default' && showBorder && 'rounded-lg border bg-card', variant === 'default' && !showBorder && 'rounded-lg bg-card', variant === 'process' && 'rounded-lg border bg-card', variant === 'process-sub' && 'rounded-md border bg-card/50', isOpen && !showBorder && 'bg-background' )} > <Collapsible open={isOpen} onOpenChange={onOpenChange} className="w-full" > <div className={cn( 'flex items-center w-full gap-2 overflow-hidden', variant === 'default' && 'justify-between px-3 py-2', variant === 'minimal' && 'py-1', variant === 'process' && 'justify-between px-1.5 py-1', variant === 'process-sub' && 'justify-between px-1 py-0.5' )} > {header && ( <div className={cn( 'overflow-hidden min-w-0', variant === 'default' && 'text-sm flex-1', variant === 'minimal' && 'text-sm flex items-center gap-1', (variant === 'process' || variant === 'process-sub') && 'text-xs flex-1' )} onClick={ headerClickBehavior === 'inspect' ? undefined : headerClickBehavior === 'split' ? undefined : onOpenChange ? () => onOpenChange(!isOpen) : undefined } > {header} </div> )} {(variant === 'minimal' || variant === 'process' || variant === 'process-sub') && ( <CollapsibleTrigger asChild> <button type="button" className={cn( 'rounded-md group cursor-pointer hover:bg-accent/50', variant === 'minimal' && 'p-0.5', (variant === 'process' || variant === 'process-sub') && 'p-0.5' )} aria-label={isOpen ? 'Collapse' : 'Expand'} > <ChevronDown className={cn( 'text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180', chevronSize === 'sm' ? 'h-3 w-3' : 'h-4 w-4' )} /> </button> </CollapsibleTrigger> )} {variant === 'default' && ( <CollapsibleTrigger asChild> <button type="button" className="p-1 hover:bg-accent rounded-md transition-transform duration-200 group" aria-label={isOpen ? 'Collapse' : 'Expand'} > <ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" /> </button> </CollapsibleTrigger> )} </div> <CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down"> {showSeparator && variant === 'default' && ( <Separator className="mb-2 border-border/50" /> )} <div className={cn( variant === 'default' && 'px-3 pb-2', variant === 'minimal' && 'pt-2', variant === 'process' && 'px-1.5 pb-1', variant === 'process-sub' && 'px-1 pb-0.5' )} > {content} </div> </CollapsibleContent> </Collapsible> </div> ) : ( <div className={cn( 'flex-1 rounded-2xl w-full', role === 'assistant' ? 'px-0' : 'px-3' )} > {content} </div> )} </div> ) } ================================================ FILE: components/current-user-avatar.tsx ================================================ 'use client' import { User2 } from 'lucide-react' import { useCurrentUserImage } from '@/hooks/use-current-user-image' import { useCurrentUserName } from '@/hooks/use-current-user-name' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' export const CurrentUserAvatar = () => { const profileImage = useCurrentUserImage() const name = useCurrentUserName() const initials = name ?.split(' ') ?.map(word => word[0]) ?.join('') ?.toUpperCase() return ( <Avatar className="size-6"> {profileImage && <AvatarImage src={profileImage} alt={initials} />} <AvatarFallback> {initials === '?' ? ( <User2 size={16} className="text-muted-foreground" /> ) : ( initials )} </AvatarFallback> </Avatar> ) } ================================================ FILE: components/custom-link.tsx ================================================ import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' import type { SearchResultItem } from '@/lib/types' import { cn } from '@/lib/utils' import { useCitation } from './citation-context' import { CitationLink } from './citation-link' type CustomLinkProps = Omit< DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, 'ref' > export function Citing({ href, children, className, ...props }: CustomLinkProps) { const { citationMaps } = useCitation() const childrenText = children?.toString() || '' // Match domain names (alphanumeric and hyphens) or numbers for backward compatibility const isCitation = /^[\w-]+$/.test(childrenText) // Get citation data if this is a citation let citationData: SearchResultItem | undefined = undefined if (isCitation && citationMaps && href) { const decodedHref = decodeURI(href) // Try to find the citation data by checking all citation maps // Match by URL instead of citation number/text for (const toolCallId in citationMaps) { const citationMap = citationMaps[toolCallId] // Search through all citations in this map for (const citationNum in citationMap) { if (citationMap[citationNum].url === decodedHref) { citationData = citationMap[citationNum] break } } if (citationData) break } } return ( <CitationLink href={href || '#'} className={className} citationData={citationData} {...props} > {children} </CitationLink> ) } ================================================ FILE: components/data-section.tsx ================================================ 'use client' import React from 'react' import type { DataPart } from '@/lib/types/ai' import { RelatedQuestions } from './related-questions' interface DataSectionProps { part: DataPart onQuerySelect?: (query: string) => void } export function DataSection({ part, onQuerySelect }: DataSectionProps) { switch (part.type) { case 'data-relatedQuestions': if (onQuerySelect) { return ( <RelatedQuestions data={part.data} onQuerySelect={onQuerySelect} /> ) } return null default: return null } } ================================================ FILE: components/default-skeleton.tsx ================================================ 'use client' import { Skeleton } from './ui/skeleton' export const DefaultSkeleton = () => { return ( <div className="flex flex-col gap-2 pb-4 pt-2"> {[...Array(2)].map((_, index) => ( <Skeleton key={index} className="h-6 w-full" /> ))} </div> ) } export function SearchSkeleton() { return ( <div className="flex flex-wrap gap-2 pb-0.5"> {[...Array(4)].map((_, index) => ( <div key={index} className="w-[calc(50%-0.5rem)] md:w-[calc(25%-0.5rem)]" > <Skeleton className="h-20 w-full" /> </div> ))} </div> ) } ================================================ FILE: components/drag-overlay.tsx ================================================ 'use client' import { UploadCloud } from 'lucide-react' import { cn } from '@/lib/utils' export function DragOverlay({ visible }: { visible: boolean }) { return ( <div className={cn( 'absolute inset-0 z-40 flex items-center justify-center backdrop-blur-md transition-opacity duration-200 pointer-events-none', visible ? 'opacity-100' : 'opacity-0' )} > <div className="text-center text-muted-foreground"> <UploadCloud className="mx-auto mb-4 w-10 h-10" /> <p className="text-lg font-semibold">Drop files here</p> </div> </div> ) } ================================================ FILE: components/dynamic-tool-display.tsx ================================================ 'use client' import React from 'react' // This matches the structure from AI SDK v5 type DynamicToolPart = | { type: 'dynamic-tool' toolCallId: string toolName: string state: 'input-streaming' input: unknown } | { type: 'dynamic-tool' toolCallId: string toolName: string state: 'input-available' input: unknown } | { type: 'dynamic-tool' toolCallId: string toolName: string state: 'output-available' input: unknown output: unknown } | { type: 'dynamic-tool' toolCallId: string toolName: string state: 'output-error' input: unknown errorText: string } interface DynamicToolDisplayProps { part: DynamicToolPart } export function DynamicToolDisplay({ part }: DynamicToolDisplayProps) { // Extract tool type from name const getToolType = (toolName: string) => { if (toolName.startsWith('mcp__')) { return 'MCP Tool' } else if (toolName.startsWith('dynamic__')) { return 'Dynamic Tool' } return 'Custom Tool' } // Extract display name from tool name const getDisplayName = (toolName: string) => { if (toolName.startsWith('mcp__')) { return toolName.substring(5).replace(/__/g, '.') } else if (toolName.startsWith('dynamic__')) { return toolName.substring(9) } return toolName } const toolType = getToolType(part.toolName) const displayName = getDisplayName(part.toolName) return ( <div className="dynamic-tool-container rounded-lg border p-4 my-2"> <div className="flex items-center gap-2 mb-2"> <span className="text-sm font-medium text-muted-foreground"> {toolType} </span> <span className="text-sm font-semibold">{displayName}</span> </div> {/* Input display */} {(part.state === 'input-streaming' || part.state === 'input-available' || part.state === 'output-available' || part.state === 'output-error') && ( <div className="mb-2"> <div className="text-xs text-muted-foreground mb-1">Input:</div> <pre className="text-xs bg-muted p-2 rounded overflow-auto max-h-40"> <code>{JSON.stringify(part.input, null, 2)}</code> </pre> </div> )} {/* Output display */} {part.state === 'output-available' && ( <div className="mb-2"> <div className="text-xs text-muted-foreground mb-1">Output:</div> <pre className="text-xs bg-muted p-2 rounded overflow-auto max-h-40"> <code>{JSON.stringify(part.output, null, 2)}</code> </pre> </div> )} {/* Error display */} {part.state === 'output-error' && ( <div className="mb-2"> <div className="text-xs text-destructive mb-1">Error:</div> <div className="text-xs bg-destructive/10 text-destructive p-2 rounded"> {part.errorText} </div> </div> )} {/* Status indicator */} <div className="flex items-center gap-2 mt-2"> <div className={`h-2 w-2 rounded-full ${ part.state === 'input-streaming' ? 'bg-blue-500 animate-pulse' : part.state === 'input-available' ? 'bg-blue-500' : part.state === 'output-available' ? 'bg-green-500' : part.state === 'output-error' ? 'bg-red-500' : 'bg-gray-300' }`} /> <span className="text-xs text-muted-foreground"> {part.state === 'input-streaming' ? 'Streaming...' : part.state === 'input-available' ? 'Processing...' : part.state === 'output-available' ? 'Complete' : part.state === 'output-error' ? 'Failed' : 'Unknown'} </span> </div> </div> ) } ================================================ FILE: components/error-modal.tsx ================================================ 'use client' import Link from 'next/link' import { AlertCircle, Clock, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' interface ErrorModalProps { open: boolean onOpenChange: (open: boolean) => void error: { type: 'rate-limit' | 'auth' | 'forbidden' | 'general' message: string details?: string } onRetry?: () => void onAuthClose?: () => void } export function ErrorModal({ open, onOpenChange, error, onRetry, onAuthClose }: ErrorModalProps) { const handleAuthClose = () => { onOpenChange(false) onAuthClose?.() } const getErrorIcon = () => { switch (error.type) { case 'rate-limit': return <Clock className="size-6 text-yellow-500" /> case 'auth': case 'forbidden': return <AlertCircle className="size-6 text-red-500" /> default: return <AlertCircle className="size-6 text-orange-500" /> } } const getErrorTitle = () => { switch (error.type) { case 'rate-limit': return 'Rate Limit Exceeded' case 'auth': return 'Continue with Morphic' case 'forbidden': return 'Access Denied' default: return 'Error Occurred' } } const getErrorDescription = () => { switch (error.type) { case 'rate-limit': return ( error.message || 'You have made too many requests. Please wait a moment before trying again.' ) case 'auth': return 'To use Morphic, sign in to your account or create a new one.' case 'forbidden': return 'You do not have permission to access this resource.' default: return ( error.message || 'An unexpected error occurred. Please try again.' ) } } const getErrorDetails = () => { if (error.type === 'rate-limit') { return 'The limit will reset at midnight UTC. You can continue using speed mode without restrictions.' } return error.details } return ( <Dialog open={open} onOpenChange={open => { if (!open && error.type === 'auth') { handleAuthClose() } else { onOpenChange(open) } }} > <DialogContent className="sm:max-w-md"> <DialogHeader> <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-muted"> {getErrorIcon()} </div> <DialogTitle className="text-center text-xl font-semibold"> {getErrorTitle()} </DialogTitle> <DialogDescription className="text-center text-muted-foreground"> {getErrorDescription()} </DialogDescription> {getErrorDetails() && ( <div className="mt-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground"> {getErrorDetails()} </div> )} </DialogHeader> <DialogFooter className="flex-col gap-2"> {error.type === 'auth' ? ( <> <Button asChild className="w-full"> <Link href="/auth/sign-up">Sign Up</Link> </Button> <Button asChild variant="outline" className="w-full"> <Link href="/auth/login">Sign In</Link> </Button> </> ) : ( <> {onRetry && error.type !== 'rate-limit' && ( <Button onClick={() => { onRetry() onOpenChange(false) }} className="w-full" > <RefreshCw className="mr-2 size-4" /> Try Again </Button> )} <Button variant={ onRetry && error.type !== 'rate-limit' ? 'outline' : 'default' } onClick={() => onOpenChange(false)} className="w-full" > {error.type === 'rate-limit' ? 'Understood' : 'Close'} </Button> </> )} </DialogFooter> </DialogContent> </Dialog> ) } ================================================ FILE: components/external-link-items.tsx ================================================ 'use client' import { SiDiscord, SiGithub, SiX } from 'react-icons/si' import Link from 'next/link' import { DropdownMenuItem } from '@/components/ui/dropdown-menu' const externalLinks = [ { name: 'X', href: 'https://x.com/morphic_ai', icon: <SiX className="mr-2 h-4 w-4" /> }, { name: 'Discord', href: 'https://discord.gg/zRxaseCuGq', icon: <SiDiscord className="mr-2 h-4 w-4" /> }, { name: 'GitHub', href: 'https://git.new/morphic', icon: <SiGithub className="mr-2 h-4 w-4" /> } ] export function ExternalLinkItems() { return ( <> {externalLinks.map(link => ( <DropdownMenuItem key={link.name} asChild> <Link href={link.href} target="_blank" rel="noopener noreferrer"> {link.icon} <span>{link.name}</span> </Link> </DropdownMenuItem> ))} </> ) } ================================================ FILE: components/feedback-modal.tsx ================================================ 'use client' import { useState, useTransition } from 'react' import { Frown, Meh, Smile } from 'lucide-react' import { toast } from 'sonner' import { submitFeedback } from '@/lib/actions/site-feedback' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Textarea } from '@/components/ui/textarea' type Sentiment = 'positive' | 'neutral' | 'negative' interface FeedbackModalProps { open: boolean onOpenChange: (open: boolean) => void } export function FeedbackModal({ open, onOpenChange }: FeedbackModalProps) { const [sentiment, setSentiment] = useState<Sentiment | null>(null) const [message, setMessage] = useState('') const [isPending, startTransition] = useTransition() const handleSubmit = () => { if (!sentiment || !message.trim()) { toast.error('Please select your sentiment and write a message') return } startTransition(async () => { const result = await submitFeedback({ sentiment, message: message.trim(), pageUrl: window.location.href }) if (result.success) { toast.success('Thank you for your feedback!') // Reset form and close modal setSentiment(null) setMessage('') onOpenChange(false) } else { toast.error('Failed to submit feedback. Please try again later.') } }) } const handleCancel = () => { setSentiment(null) setMessage('') onOpenChange(false) } return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[550px]"> <DialogHeader> <DialogTitle>Give feedback</DialogTitle> <DialogDescription> Your feedback helps us improve Morphic. Let us know what you think! </DialogDescription> </DialogHeader> <div className="space-y-4 mt-4"> <div className="flex gap-2"> <Button type="button" variant={sentiment === 'positive' ? 'default' : 'outline'} size="icon" onClick={() => setSentiment('positive')} className={cn( 'h-12 w-12', sentiment === 'positive' && 'bg-green-500 hover:bg-green-600' )} > <Smile className="h-6 w-6" /> </Button> <Button type="button" variant={sentiment === 'neutral' ? 'default' : 'outline'} size="icon" onClick={() => setSentiment('neutral')} className={cn( 'h-12 w-12', sentiment === 'neutral' && 'bg-yellow-500 hover:bg-yellow-600' )} > <Meh className="h-6 w-6" /> </Button> <Button type="button" variant={sentiment === 'negative' ? 'default' : 'outline'} size="icon" onClick={() => setSentiment('negative')} className={cn( 'h-12 w-12', sentiment === 'negative' && 'bg-red-500 hover:bg-red-600' )} > <Frown className="h-6 w-6" /> </Button> </div> <Textarea placeholder="Your feedback" value={message} onChange={e => setMessage(e.target.value)} className="min-h-[150px] resize-none" /> <div className="flex justify-end gap-2"> <Button type="button" variant="outline" onClick={handleCancel} disabled={isPending} > Cancel </Button> <Button type="button" onClick={handleSubmit} disabled={isPending || !sentiment || !message.trim()} > {isPending ? 'Submitting...' : 'Submit'} </Button> </div> </div> </DialogContent> </Dialog> ) } ================================================ FILE: components/fetch-section.tsx ================================================ 'use client' import { UseChatHelpers } from '@ai-sdk/react' import { AlertCircle, Check, ExternalLink, Globe } from 'lucide-react' import { SearchResults as SearchResultsType } from '@/lib/types' import type { ToolPart, UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import { cn } from '@/lib/utils' import ProcessHeader from './process-header' interface FetchSectionProps { tool: ToolPart<'fetch'> isOpen: boolean onOpenChange: (open: boolean) => void status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] borderless?: boolean isFirst?: boolean isLast?: boolean } export function FetchSection({ tool, isOpen, // Required by ToolSection interface onOpenChange, // Required by ToolSection interface status, borderless = false, isFirst = false, isLast = false }: FetchSectionProps) { const url = tool.input?.url const isLoading = status === 'submitted' || status === 'streaming' const isToolLoading = tool.state === 'input-streaming' || tool.state === 'input-available' // Handle streaming output states const output = tool.state === 'output-available' ? tool.output : undefined const isFetching = output?.state === 'fetching' const fetchResults = output?.state === 'complete' ? output : undefined // Determine the status based on tool output availability let displayStatus: 'fetching' | 'success' | 'error' = 'fetching' let error: string | undefined let title: string | undefined let contentLength: number | undefined // Check tool state if (tool.state === 'output-error') { displayStatus = 'error' error = tool.errorText || 'Failed to retrieve content' } else if (!output || isFetching) { displayStatus = 'fetching' } else if (fetchResults) { // Success state - we have complete output const data = fetchResults as SearchResultsType if (data?.results?.[0]) { displayStatus = 'success' title = data.results[0].title contentLength = data.results[0].content?.length } else { displayStatus = 'error' error = 'No content retrieved' } } // Get page title for display const getPageTitle = () => { if (title) return title if (!url) return 'Unknown URL' try { const domain = new URL(url).hostname return domain.replace('www.', '') } catch { return url } } // Handle click to open URL const handleClick = () => { if (url && displayStatus === 'success') { window.open(url, '_blank', 'noopener,noreferrer') } } const header = ( <ProcessHeader onInspect={handleClick} isLoading={isLoading && isToolLoading} label={ <div className="flex items-center gap-2 min-w-0 overflow-hidden"> <Globe className="h-4 w-4 shrink-0 text-muted-foreground" /> <span className="truncate block min-w-0 max-w-full"> {getPageTitle()} </span> </div> } meta={ displayStatus === 'success' && contentLength ? ( <> <Check size={16} className="text-green-500" /> <span> {contentLength > 1000 ? `${Math.round(contentLength / 1000)}k chars` : `${contentLength} chars`} </span> </> ) : displayStatus === 'error' ? ( <> <AlertCircle size={16} className="text-destructive" /> <span>{error}</span> </> ) : isToolLoading ? ( <span className="animate-pulse">Retrieving...</span> ) : undefined } className={cn( displayStatus === 'success' && 'hover:text-foreground cursor-pointer' )} /> ) return ( <div className="relative"> {/* Rails for header - show based on position */} {borderless && ( <> {!isFirst && ( <div className="absolute left-[19.5px] w-px bg-border h-2 top-0" /> )} {!isLast && ( <div className="absolute left-[19.5px] w-px bg-border h-2 bottom-0" /> )} </> )} {/* Header only section - no collapsible body */} <div className={cn( 'rounded-lg', !borderless && 'bg-card border border-border' )} > <div className="flex items-center gap-2 p-3"> <div className="flex-1 min-w-0">{header}</div> {displayStatus === 'success' && ( <button type="button" onClick={handleClick} className="shrink-0 p-1 hover:bg-accent rounded transition-colors" aria-label="Open in new tab" > <ExternalLink className="h-4 w-4 text-muted-foreground" /> </button> )} </div> </div> </div> ) } export default FetchSection ================================================ FILE: components/file-upload-button.tsx ================================================ 'use client' import { useRef, useState } from 'react' import { Paperclip } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Button } from './ui/button' const allowedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] const allowedOtherTypes = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ] const isAllowedFileType = (file: File) => allowedImageTypes.includes(file.type) || allowedOtherTypes.includes(file.type) export function FileUploadButton({ onFileSelect }: { onFileSelect: (files: File[]) => void }) { const inputRef = useRef<HTMLInputElement>(null) const [isDragging, setIsDragging] = useState(false) const handleFiles = (files: FileList | null) => { if (!files) return const fileArray = Array.from(files).slice(0, 3) const validFiles = fileArray.filter(isAllowedFileType) const rejected = fileArray.filter(f => !isAllowedFileType(f)) if (rejected.length > 0) { toast.error( 'Some files were not accepted: ' + rejected.map(f => f.name).join(', ') ) } if (validFiles.length > 0) { onFileSelect(validFiles) } } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) handleFiles(e.dataTransfer.files) } return ( <div onDragOver={e => { e.preventDefault() setIsDragging(true) }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={cn( 'relative rounded-full', isDragging && 'ring-2 ring-blue-500 ring-offset-2' )} title="Drag and drop or click to upload" > <input ref={inputRef} type="file" accept="image/*,.pdf,.doc,.docx" hidden multiple onChange={e => { handleFiles(e.target.files) e.target.value = '' }} /> <Button variant="outline" size="icon" className="rounded-full" type="button" onClick={() => inputRef.current?.click()} > <Paperclip size={18} /> </Button> </div> ) } ================================================ FILE: components/forgot-password-form.tsx ================================================ 'use client' import { useState } from 'react' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils/index' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' export function ForgotPasswordForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { const [email, setEmail] = useState('') const [error, setError] = useState<string | null>(null) const [success, setSuccess] = useState(false) const [isLoading, setIsLoading] = useState(false) const handleForgotPassword = async (e: React.FormEvent) => { e.preventDefault() const supabase = createClient() setIsLoading(true) setError(null) try { // The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/auth/update-password` }) if (error) throw error setSuccess(true) } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { setIsLoading(false) } } return ( <div className={cn('flex flex-col gap-6', className)} {...props}> {success ? ( <Card> <CardHeader> <CardTitle className="text-2xl">Check Your Email</CardTitle> <CardDescription>Password reset instructions sent</CardDescription> </CardHeader> <CardContent> <p className="text-sm text-muted-foreground"> If you registered using your email and password, you will receive a password reset email. </p> </CardContent> </Card> ) : ( <Card> <CardHeader> <CardTitle className="text-2xl">Reset Your Password</CardTitle> <CardDescription> Type in your email and we'll send you a link to reset your password </CardDescription> </CardHeader> <CardContent> <form onSubmit={handleForgotPassword}> <div className="flex flex-col gap-6"> <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="m@example.com" required value={email} onChange={e => setEmail(e.target.value)} /> </div> {error && <p className="text-sm text-red-500">{error}</p>} <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? 'Sending...' : 'Send reset email'} </Button> </div> <div className="mt-4 text-center text-sm"> Already have an account?{' '} <Link href="/auth/login" className="underline underline-offset-4" > Login </Link> </div> </form> </CardContent> </Card> )} </div> ) } ================================================ FILE: components/guest-menu.tsx ================================================ 'use client' import Link from 'next/link' import { Link2, LogIn, Palette, Settings2 // Or EllipsisVertical, etc. } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { ExternalLinkItems } from './external-link-items' import { ThemeMenuItems } from './theme-menu-items' export default function GuestMenu() { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className="h-6 w-6"> <Settings2 className="h-4 w-4" /> {/* Choose an icon */} <span className="sr-only">Open menu</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuItem asChild> <Link href="/auth/login"> <LogIn className="mr-2 h-4 w-4" /> <span>Sign In</span> </Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger> <Palette className="mr-2 h-4 w-4" /> <span>Theme</span> </DropdownMenuSubTrigger> <DropdownMenuSubContent> <ThemeMenuItems /> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSub> <DropdownMenuSubTrigger> <Link2 className="mr-2 h-4 w-4" /> <span>Links</span> </DropdownMenuSubTrigger> <DropdownMenuSubContent> <ExternalLinkItems /> </DropdownMenuSubContent> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> ) } ================================================ FILE: components/header.tsx ================================================ 'use client' // import Link from 'next/link' // No longer needed directly here for Sign In button import React, { useState } from 'react' import { usePathname } from 'next/navigation' import { User } from '@supabase/supabase-js' import { cn } from '@/lib/utils' import { useSidebar } from '@/components/ui/sidebar' import { Button } from './ui/button' import { FeedbackModal } from './feedback-modal' // import { Button } from './ui/button' // No longer needed directly here for Sign In button import GuestMenu from './guest-menu' // Import the new GuestMenu component import UserMenu from './user-menu' interface HeaderProps { user: User | null } export const Header: React.FC<HeaderProps> = ({ user }) => { const { open } = useSidebar() const pathname = usePathname() const [feedbackOpen, setFeedbackOpen] = useState(false) const isRootPage = pathname === '/' return ( <> <header className={cn( 'absolute top-0 right-0 p-3 flex justify-between items-center z-10 backdrop-blur-sm lg:backdrop-blur-none bg-background/80 lg:bg-transparent transition-[width] duration-200 ease-linear', open ? 'md:w-[calc(100%-var(--sidebar-width))]' : 'md:w-full', 'w-full' )} > {/* This div can be used for a logo or title on the left if needed */} <div></div> <div className="flex items-center gap-2"> {isRootPage && ( <Button variant="outline" size="sm" onClick={() => setFeedbackOpen(true)} > Feedback </Button> )} {user ? <UserMenu user={user} /> : <GuestMenu />} </div> </header> {isRootPage && ( <FeedbackModal open={feedbackOpen} onOpenChange={setFeedbackOpen} /> )} </> ) } export default Header ================================================ FILE: components/inspector/inspector-drawer.tsx ================================================ 'use client' import { VisuallyHidden } from '@radix-ui/react-visually-hidden' import { useMediaQuery } from '@/lib/hooks/use-media-query' import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer' import { useArtifact } from '@/components/artifact/artifact-context' import { InspectorPanel } from './inspector-panel' export function InspectorDrawer() { const { state, close } = useArtifact() const part = state.part const isMobile = useMediaQuery('(max-width: 767px)') // Function to get the title based on part type (mirrors ArtifactPanel logic) const getTitle = () => { if (!part) return 'Artifact' // Default title switch (part.type) { case 'tool-search': return 'search' case 'tool-fetch': return 'fetch' case 'tool-askQuestion': return 'askQuestion' case 'reasoning': return 'Thoughts' case 'text': return 'Text' default: return 'Content' } } if (!isMobile) return null return ( <Drawer open={state.isOpen} onOpenChange={open => { if (!open) close() }} modal={true} > <DrawerContent className="p-0 max-h-[90vh] md:hidden"> <DrawerTitle asChild> <VisuallyHidden>{getTitle()}</VisuallyHidden> </DrawerTitle> <InspectorPanel /> </DrawerContent> </Drawer> ) } ================================================ FILE: components/inspector/inspector-panel.tsx ================================================ 'use client' import { LightbulbIcon, ListTodo, MessageSquare, Minimize2, Search } from 'lucide-react' import { Separator } from '@/components/ui/separator' import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipButton } from '@/components/ui/tooltip-button' import { ArtifactContent } from '@/components/artifact/artifact-content' import { useArtifact } from '@/components/artifact/artifact-context' export function InspectorPanel() { const { state, close } = useArtifact() const part = state.part if (!part) return null // Get the icon and title based on part type const getIconAndTitle = () => { switch (part.type) { case 'tool-search': case 'tool-askQuestion': const toolName = part.type.replace('tool-', '') return { icon: <Search size={18} />, title: toolName } case 'tool-todoWrite': return { icon: <ListTodo size={18} />, title: 'Todo List' } case 'reasoning': return { icon: <LightbulbIcon size={18} />, title: 'Thoughts' } case 'text': return { icon: <MessageSquare size={18} />, title: 'Text' } default: return { icon: <MessageSquare size={18} />, title: 'Content' } } } const { icon, title } = getIconAndTitle() return ( <TooltipProvider> <div className="h-full flex flex-col overflow-hidden bg-muted md:px-4 md:pt-14 md:pb-4"> <div className="flex flex-col h-full bg-background rounded-xl md:border overflow-hidden"> <div className="flex items-center justify-between px-4 py-2"> <h3 className="flex items-center gap-2"> <div className="p-2 rounded-md flex items-center gap-2"> {icon} </div> <span className="text-sm font-medium capitalize">{title}</span> </h3> <TooltipButton variant="ghost" size="icon" onClick={close} aria-label="Close panel" tooltipContent="Minimize" > <Minimize2 className="h-4 w-4" /> </TooltipButton> </div> <Separator className="my-1 bg-border/50" /> <div data-vaul-no-drag className="flex-1 overflow-y-auto p-4"> <ArtifactContent part={part} /> </div> </div> </div> </TooltipProvider> ) } ================================================ FILE: components/login-form.tsx ================================================ 'use client' import { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils/index' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { IconLogo } from '@/components/ui/icons' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { PasswordInput } from './ui/password-input' export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState<string | null>(null) const [isLoading, setIsLoading] = useState(false) const router = useRouter() const handleLogin = async (e: React.FormEvent) => { e.preventDefault() const supabase = createClient() setIsLoading(true) setError(null) try { const { error } = await supabase.auth.signInWithPassword({ email, password }) if (error) throw error // Redirect to root and refresh to ensure server components get updated session router.push('/') router.refresh() } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { setIsLoading(false) } } const handleSocialLogin = async () => { const supabase = createClient() setIsLoading(true) setError(null) try { const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: `${location.origin}/auth/oauth` } }) if (error) throw error } catch (error: unknown) { setError( error instanceof Error ? error.message : 'An OAuth error occurred' ) } finally { setIsLoading(false) } } return ( <div className={cn('flex flex-col items-center gap-6', className)} {...props} > <Card className="w-full max-w-sm"> <CardHeader className="text-center"> <CardTitle className="text-2xl flex flex-col items-center justify-center gap-4"> <IconLogo className="size-12" /> Welcome back </CardTitle> <CardDescription>Sign in to your account</CardDescription> </CardHeader> <CardContent> <div className="flex flex-col gap-4"> <Button variant="outline" type="button" className="w-full" onClick={handleSocialLogin} disabled={isLoading} > Sign In with Google </Button> <div className="relative my-2"> <div className="absolute inset-0 flex items-center"> <span className="w-full border-t" /> </div> <div className="relative flex justify-center text-xs uppercase"> <span className="bg-muted px-2 text-muted-foreground">Or</span> </div> </div> <form onSubmit={handleLogin} className="flex flex-col gap-4"> <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="you@example.com" required value={email} onChange={e => setEmail(e.target.value)} /> </div> <div className="grid gap-2"> <div className="flex items-center"> <Label htmlFor="password">Password</Label> <Link href="/auth/forgot-password" className="ml-auto inline-block text-sm underline-offset-4 hover:underline" > Forgot password? </Link> </div> <PasswordInput id="password" type="password" placeholder="********" required value={password} onChange={e => setPassword(e.target.value)} /> </div> {error && <p className="text-sm text-red-500">{error}</p>} <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? 'Logging in...' : 'Sign In'} </Button> </form> </div> <div className="mt-6 text-center text-sm"> Don't have an account?{' '} <Link href="/auth/sign-up" className="underline underline-offset-4"> Sign Up </Link> </div> </CardContent> </Card> <div className="text-center text-xs text-muted-foreground"> <Link href="/" className="hover:underline"> ← Back to Home </Link> </div> </div> ) } ================================================ FILE: components/message-actions.tsx ================================================ 'use client' import { useMemo, useState } from 'react' import { UseChatHelpers } from '@ai-sdk/react' import { Copy, ThumbsDown, ThumbsUp } from 'lucide-react' import { toast } from 'sonner' import type { SearchResultItem } from '@/lib/types' import type { UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import { cn } from '@/lib/utils' import { processCitations } from '@/lib/utils/citation' import { Button } from './ui/button' import { ChatShare } from './chat-share' import { RetryButton } from './retry-button' interface MessageActionsProps { message: string messageId: string traceId?: string feedbackScore?: number | null reload?: () => Promise<void | string | null | undefined> chatId?: string enableShare?: boolean className?: string status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] visible?: boolean citationMaps?: Record<string, Record<number, SearchResultItem>> } export function MessageActions({ message, messageId, traceId, feedbackScore: initialFeedbackScore, reload, chatId, enableShare, className, status, visible = true, citationMaps }: MessageActionsProps) { const [feedbackScore, setFeedbackScore] = useState<number | null>( initialFeedbackScore ?? null ) const mappedMessage = useMemo(() => { if (!message) return '' return processCitations(message, citationMaps || {}) }, [message, citationMaps]) const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false) const isLoading = status === 'submitted' || status === 'streaming' // Keep the element mounted during loading to preserve layout; otherwise skip rendering. if (!visible && !isLoading) { return null } async function handleCopy() { await navigator.clipboard.writeText(mappedMessage) toast.success('Message copied to clipboard') } async function handleFeedback(score: number) { if (isSubmittingFeedback || !traceId) return setIsSubmittingFeedback(true) try { const response = await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ traceId, score, messageId }) }) if (response.ok) { setFeedbackScore(score) toast.success( score === 1 ? 'Thanks for the feedback!' : 'Thanks for letting us know!' ) } else { console.error('Failed to submit feedback') toast.error('Failed to submit feedback') } } catch (error) { console.error('Error submitting feedback:', error) toast.error('Failed to submit feedback') } finally { setIsSubmittingFeedback(false) } } return ( <div aria-hidden={!visible} className={cn( 'flex items-center gap-0.5 self-end transition-opacity duration-200', visible ? 'opacity-100' : 'pointer-events-none opacity-0 invisible', className )} > {reload && <RetryButton reload={reload} messageId={messageId} />} <Button variant="ghost" size="icon" onClick={handleCopy} className="rounded-full" > <Copy size={14} /> </Button> {traceId && ( <> {(feedbackScore === null || feedbackScore === 1) && ( <Button variant="ghost" size="icon" onClick={() => handleFeedback(1)} disabled={isSubmittingFeedback || feedbackScore === 1} className="rounded-full" > <ThumbsUp size={14} className={feedbackScore === 1 ? 'fill-current' : ''} /> </Button> )} {(feedbackScore === null || feedbackScore === -1) && ( <Button variant="ghost" size="icon" onClick={() => handleFeedback(-1)} disabled={isSubmittingFeedback || feedbackScore === -1} className="rounded-full" > <ThumbsDown size={14} className={feedbackScore === -1 ? 'fill-current' : ''} /> </Button> )} </> )} {enableShare && chatId && <ChatShare chatId={chatId} />} </div> ) } ================================================ FILE: components/message.tsx ================================================ 'use client' import rehypeExternalLinks from 'rehype-external-links' import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { Streamdown } from 'streamdown' import type { SearchResultItem } from '@/lib/types' import { cn } from '@/lib/utils' import { processCitations } from '@/lib/utils/citation' import { CitationProvider } from './citation-context' import { Citing } from './custom-link' import 'katex/dist/katex.min.css' export function MarkdownMessage({ message, className, citationMaps }: { message: string className?: string citationMaps?: Record<string, Record<number, SearchResultItem>> }) { // Process citations to replace [number](#toolCallId) with [number](actual-url) const processedMessage = processCitations(message || '', citationMaps || {}) // Define custom components for links (use Streamdown defaults for code blocks) const customComponents = { a: Citing } return ( <CitationProvider citationMaps={citationMaps}> <div className={cn( 'prose-sm prose-neutral prose-a:text-accent-foreground/50', className )} > <Streamdown rehypePlugins={[ [rehypeExternalLinks, { target: '_blank' }], [rehypeKatex] ]} remarkPlugins={[remarkGfm, remarkMath]} components={customComponents} > {processedMessage} </Streamdown> </div> </CitationProvider> ) } ================================================ FILE: components/model-type-selector.tsx ================================================ 'use client' import { useEffect, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { ModelType } from '@/lib/types/model-type' import { getCookie, setCookie } from '@/lib/utils/cookies' import { Button } from './ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu' const MODEL_TYPE_OPTIONS: { value: ModelType; label: string }[] = [ { value: 'speed', label: 'Speed' }, { value: 'quality', label: 'Quality' } ] export function ModelTypeSelector({ disabled = false }: { disabled?: boolean }) { const [value, setValue] = useState<ModelType>('speed') const [dropdownOpen, setDropdownOpen] = useState(false) useEffect(() => { if (disabled) { setValue('speed') setCookie('modelType', 'speed') return } const savedType = getCookie('modelType') if (savedType && ['speed', 'quality'].includes(savedType)) { setValue(savedType as ModelType) } }, [disabled]) const handleTypeSelect = (type: ModelType) => { if (disabled) return setValue(type) setCookie('modelType', type) setDropdownOpen(false) } const selectedOption = MODEL_TYPE_OPTIONS.find(opt => opt.value === value) return ( <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenuTrigger asChild> <Button variant="outline" className="text-sm rounded-full shadow-none gap-1 transition-all px-3 py-2 h-auto bg-muted border-none" disabled={disabled} > <span className="text-xs font-medium">{selectedOption?.label}</span> <ChevronDown className={`h-3 w-3 ml-0.5 opacity-50 transition-transform duration-200 ${ dropdownOpen ? 'rotate-180' : '' }`} /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="min-w-[120px]" sideOffset={5} > {MODEL_TYPE_OPTIONS.map(option => { const isSelected = value === option.value return ( <DropdownMenuItem key={option.value} onClick={() => handleTypeSelect(option.value)} className="relative flex items-center cursor-pointer" > <div className="w-4 h-4 mr-2 flex items-center justify-center"> {isSelected && <Check className="h-3 w-3" />} </div> <span className="text-sm">{option.label}</span> </DropdownMenuItem> ) })} </DropdownMenuContent> </DropdownMenu> ) } ================================================ FILE: components/process-header.tsx ================================================ 'use client' import type { ReactNode } from 'react' import { cn } from '@/lib/utils' export type ProcessHeaderProps = { label: ReactNode meta?: ReactNode onInspect?: () => void isLoading?: boolean ariaExpanded?: boolean className?: string } export function ProcessHeader({ label, meta, onInspect, isLoading, ariaExpanded, className }: ProcessHeaderProps) { return ( <button type="button" onClick={onInspect} aria-expanded={ariaExpanded} className={cn( 'flex items-center justify-between w-full min-w-0 text-left text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer', isLoading && 'animate-pulse', className )} > <div className="min-w-0 max-w-full flex-1 overflow-hidden">{label}</div> {meta ? ( <span className="shrink-0 ml-2 text-xs text-muted-foreground flex items-center gap-1"> {meta} </span> ) : null} </button> ) } export default ProcessHeader ================================================ FILE: components/process-rail.tsx ================================================ import { cn } from '@/lib/utils' interface ProcessRailProps { isFirst: boolean isLast: boolean isSubStep?: boolean } export function ProcessRail({ isFirst, isLast, isSubStep = false }: ProcessRailProps) { return ( <div className="relative flex flex-col items-center w-5 h-full"> {/* Connector line above */} {!isFirst && ( <div className="absolute w-px bg-border/50 -top-full h-full" /> )} {/* Dot positioned at header center - aligned with "Thoughts" text */} <div className={cn( 'absolute rounded-full z-10', isSubStep ? 'h-1.5 w-1.5 bg-muted-foreground/50' : 'h-2 w-2 bg-muted-foreground' )} style={{ top: '0.5rem' }} // Align with header text baseline /> {/* Connector line below */} {!isLast && ( <div className="absolute w-px bg-border/50" style={{ top: '1.125rem', bottom: '-100%', height: 'calc(100% + 100%)' }} /> )} </div> ) } ================================================ FILE: components/question-confirmation.tsx ================================================ 'use client' import { useState } from 'react' import { ArrowRight, Check, SkipForward } from 'lucide-react' import type { ToolPart } from '@/lib/types/ai' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' interface QuestionConfirmationProps { toolInvocation: ToolPart<'askQuestion'> onConfirm: (toolCallId: string, approved: boolean, response?: any) => void isCompleted?: boolean } interface QuestionOption { value: string label: string } interface QuestionInput { question: string options: QuestionOption[] allowsInput?: boolean inputLabel?: string inputPlaceholder?: string } interface QuestionOutput { selectedOptions?: string[] inputText?: string skipped?: boolean } export function QuestionConfirmation({ toolInvocation, onConfirm, isCompleted = false }: QuestionConfirmationProps) { const input = (toolInvocation.input || {}) as QuestionInput const { question = '', options = [], allowsInput = false, inputLabel = '', inputPlaceholder = '' } = input // Get result data if available const resultData = toolInvocation.state === 'output-available' && toolInvocation.output ? toolInvocation.output : null const [selectedOptions, setSelectedOptions] = useState<string[]>([]) const [inputText, setInputText] = useState('') const [completed, setCompleted] = useState(isCompleted) const [skipped, setSkipped] = useState(false) const isButtonDisabled = selectedOptions.length === 0 && (!allowsInput || inputText.trim() === '') const handleOptionChange = (label: string) => { setSelectedOptions(prev => { if (prev.includes(label)) { return prev.filter(item => item !== label) } else { return [...prev, label] } }) } const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { setInputText(e.target.value) } const handleSkip = () => { setSkipped(true) setCompleted(true) onConfirm(toolInvocation.toolCallId, false, { skipped: true }) } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() const response = { selectedOptions, inputText: inputText.trim(), question } onConfirm(toolInvocation.toolCallId, true, response) setCompleted(true) } // Get options to display (from result or local state) const getDisplayedOptions = (): string[] => { const result = resultData as QuestionOutput | null if (result && Array.isArray(result.selectedOptions)) { return result.selectedOptions } return selectedOptions } // Get input text to display (from result or local state) const getDisplayedInputText = (): string => { const result = resultData as QuestionOutput | null if (result && result.inputText) { return result.inputText } return inputText } // Check if question was skipped const wasSkipped = (): boolean => { const result = resultData as QuestionOutput | null if (result && result.skipped) { return true } return skipped } const updatedQuery = () => { // If skipped, show skipped message if (wasSkipped()) { return 'Question skipped' } const displayOptions = getDisplayedOptions() const displayInputText = getDisplayedInputText() const optionsText = displayOptions.length > 0 ? `Selected: ${displayOptions.join(', ')}` : '' const inputTextDisplay = displayInputText.trim() !== '' ? `Input: ${displayInputText}` : '' return [optionsText, inputTextDisplay].filter(Boolean).join(' | ') } // Show result view if completed or if tool has result state if (completed || toolInvocation.state === 'output-available') { const isSkipped = wasSkipped() return ( <Card className="p-3 md:p-4 w-full flex flex-col justify-between items-center gap-2"> <CardTitle className="text-base font-medium text-muted-foreground w-full"> {question} </CardTitle> <div className="flex items-center justify-start gap-1 w-full"> {isSkipped ? ( <SkipForward size={16} className="text-yellow-500 w-4 h-4" /> ) : ( <Check size={16} className="text-green-500 w-4 h-4" /> )} <h5 className="text-muted-foreground text-xs truncate"> {updatedQuery()} </h5> </div> </Card> ) } return ( <Card> <CardHeader> <CardTitle className="text-lg">{question}</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit}> <div className="flex flex-wrap justify-start mb-4"> {options && options.map((option: QuestionOption, index: number) => ( <div key={`option-${index}`} className="flex items-center space-x-1.5 mb-2" > <Checkbox id={option.value} checked={selectedOptions.includes(option.label)} onCheckedChange={() => handleOptionChange(option.label)} /> <label className="text-sm whitespace-nowrap pr-4" htmlFor={option.value} > {option.label} </label> </div> ))} </div> {allowsInput && ( <div className="mb-6 flex flex-col space-y-2 text-sm"> <label className="text-muted-foreground" htmlFor="query"> {inputLabel} </label> <Input type="text" name="additional_query" className="w-full" id="query" placeholder={inputPlaceholder} value={inputText} onChange={handleInputChange} /> </div> )} <div className="flex justify-end space-x-2"> <Button type="button" variant="outline" onClick={handleSkip}> <SkipForward size={16} className="mr-1" /> Skip </Button> <Button type="submit" disabled={isButtonDisabled}> <ArrowRight size={16} className="mr-1" /> Send </Button> </div> </form> </CardContent> </Card> ) } ================================================ FILE: components/reasoning-section.tsx ================================================ 'use client' import { useEffect, useState } from 'react' import type { ReasoningPart } from '@ai-sdk/provider-utils' import { cn } from '@/lib/utils' import { useArtifact } from '@/components/artifact/artifact-context' import { CollapsibleMessage } from './collapsible-message' import { DefaultSkeleton } from './default-skeleton' import { MarkdownMessage } from './message' import ProcessHeader from './process-header' interface ReasoningContent { reasoning: string isDone: boolean } export interface ReasoningSectionProps { content: ReasoningContent isOpen: boolean onOpenChange: (open: boolean) => void showIcon?: boolean variant?: 'default' | 'minimal' | 'process' | 'process-sub' isSingle?: boolean // Whether this is a single item or part of a group isFirst?: boolean isLast?: boolean } export function ReasoningSection({ content, isOpen, onOpenChange, showIcon = false, variant = 'default', isSingle = true, isFirst = false, isLast = false }: ReasoningSectionProps) { const { open } = useArtifact() // Show a short preview when collapsed; switch to a generic label when expanded const HEADER_PREVIEW_CHARS = 120 const SANITIZE_MARKDOWN_PREVIEW = true const [preview, setPreview] = useState<string | null>(null) const toPreview = (text: string) => { const firstLine = (text || '').split(/\r?\n/)[0] || '' if (!SANITIZE_MARKDOWN_PREVIEW) return firstLine return firstLine .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // links [text](url) .replace(/`([^`]+)`/g, '$1') // inline code .replace(/\*\*([^*]+)\*\*/g, '$1') // bold **text** .replace(/__([^_]+)__/g, '$1') // bold __text__ .replace(/^#{1,6}\s*/, '') // heading markers at start } // Lock a preview during streaming to avoid frequent churn; refresh once when done useEffect(() => { const text = content?.reasoning || '' if (!text) return const prepared = toPreview(text) if (!content.isDone) { // Set once during streaming if (!preview) setPreview(prepared.slice(0, HEADER_PREVIEW_CHARS)) } else { // On completion, ensure preview reflects the final string (single update) const finalPreview = prepared.slice(0, HEADER_PREVIEW_CHARS) if (preview !== finalPreview) setPreview(finalPreview) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [content.reasoning, content.isDone]) const headerLabel = isOpen ? 'Thoughts' : preview && preview.length > 0 ? preview : !content.isDone ? 'Thinking...' : 'Thoughts' const reasoningHeader = ( <ProcessHeader label={ !isSingle ? ( <div className="flex items-center gap-2 min-w-0"> <div className="w-4 h-4 shrink-0 flex items-center justify-center relative"> <div className="w-1.5 h-1.5 rounded-full bg-muted-foreground" /> </div> <span className="truncate block min-w-0 max-w-full"> {headerLabel} </span> </div> ) : ( headerLabel ) } onInspect={() => open({ type: 'reasoning', text: content.reasoning } as ReasoningPart) } isLoading={!content.isDone} ariaExpanded={isOpen} /> ) if (!content) return <DefaultSkeleton /> // Return null if done and reasoning text is empty if (content.isDone && !content.reasoning?.trim()) return null return ( <div className="relative"> {/* Rails for header - show based on position */} {!isSingle && ( <> {!isFirst && ( <div className="absolute left-[19.5px] w-px bg-border h-2 top-0" /> )} {!isLast && ( <div className="absolute left-[19.5px] w-px bg-border h-2 bottom-0" /> )} </> )} <CollapsibleMessage role="assistant" isCollapsible={true} header={reasoningHeader} isOpen={isOpen} onOpenChange={onOpenChange} showBorder={isSingle} showIcon={showIcon} variant={variant} showSeparator={false} headerClickBehavior="split" > <div className="flex"> {/* Rail space - always reserved when grouped */} {!isSingle && ( <> <div className="w-[16px] shrink-0 flex justify-center"> <div className={cn( 'w-px bg-border/50 transition-opacity duration-200', isOpen ? 'opacity-100' : 'opacity-0' )} style={{ marginTop: isFirst ? '0' : '-1rem', marginBottom: isLast ? '0' : '-1rem' }} /> </div> <div className="w-2 shrink-0" /> </> )} <div className="[&_p]:text-xs [&_p]:text-muted-foreground/80 flex-1"> <MarkdownMessage message={content.reasoning} /> </div> </div> </CollapsibleMessage> </div> ) } ================================================ FILE: components/related-questions.tsx ================================================ 'use client' import React from 'react' import { ArrowRight } from 'lucide-react' import type { RelatedQuestionsData } from '@/lib/types/ai' import { Button } from './ui/button' import { Skeleton } from './ui/skeleton' import { CollapsibleMessage } from './collapsible-message' import { Section } from './section' interface RelatedQuestionsProps { data: RelatedQuestionsData onQuerySelect: (query: string) => void } export const RelatedQuestions: React.FC<RelatedQuestionsProps> = ({ data, onQuerySelect }) => { const renderQuestionButtons = (questions: Array<{ question: string }>) => questions.map((item, index) => ( <div className="flex items-start w-full" key={index}> <ArrowRight className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-accent-foreground/50" /> <Button variant="link" className="flex-1 justify-start px-0 py-0 h-fit font-semibold text-accent-foreground/50 whitespace-normal text-left" type="submit" name={'related_query'} value={item.question} onClick={() => onQuerySelect(item.question)} > {item.question} </Button> </div> )) return ( <CollapsibleMessage role="assistant" isCollapsible={false} isOpen={true} onOpenChange={() => {}} showIcon={false} showBorder={false} > <Section title="Related" className="pt-0 pb-4"> <div className="flex flex-col gap-2"> {data.status === 'streaming' && data.questions && ( // Show received questions immediately while the rest stream <> {renderQuestionButtons(data.questions)} {Array.from({ length: Math.max(0, 3 - data.questions.length) }).map((_, index) => ( <div className="flex items-start w-full" key={`placeholder-${index}`} > <ArrowRight className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-accent-foreground/50" /> <Skeleton className="h-6 w-full" /> </div> ))} </> )} {data.status === 'loading' && ( <> {[1, 2, 3].map((_, index) => ( <div className="flex items-start w-full" key={index}> <ArrowRight className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-accent-foreground/50" /> <Skeleton className="h-6 w-full" /> </div> ))} </> )} {data.status === 'error' && ( <div className="text-sm text-muted-foreground"> Failed to generate related questions </div> )} {data.status === 'success' && data.questions && ( <>{renderQuestionButtons(data.questions)}</> )} </div> </Section> </CollapsibleMessage> ) } export default RelatedQuestions ================================================ FILE: components/render-message.tsx ================================================ import { UseChatHelpers } from '@ai-sdk/react' import type { SearchResultItem } from '@/lib/types' import type { UIDataTypes, UIMessage, UIMessageMetadata, UITools } from '@/lib/types/ai' import type { DynamicToolPart } from '@/lib/types/dynamic-tools' import { AnswerSection } from './answer-section' import { DynamicToolDisplay } from './dynamic-tool-display' import ResearchProcessSection from './research-process-section' import { UserFileSection } from './user-file-section' import { UserTextSection } from './user-text-section' interface RenderMessageProps { message: UIMessage messageId: string getIsOpen: (id: string, partType?: string, hasNextPart?: boolean) => boolean onOpenChange: (id: string, open: boolean) => void onQuerySelect: (query: string) => void chatId?: string isGuest?: boolean status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] addToolResult?: (params: { toolCallId: string; result: any }) => void onUpdateMessage?: (messageId: string, newContent: string) => Promise<void> reload?: (messageId: string) => Promise<void | string | null | undefined> isLatestMessage?: boolean citationMaps?: Record<string, Record<number, SearchResultItem>> } export function RenderMessage({ message, messageId, getIsOpen, onOpenChange, onQuerySelect, chatId, isGuest = false, status, addToolResult, onUpdateMessage, reload, isLatestMessage = false, citationMaps = {} }: RenderMessageProps) { // Use provided citation maps (from all messages) if (message.role === 'user') { return ( <> {message.parts?.map((part: any, index: number) => { switch (part.type) { case 'text': return ( <UserTextSection key={`${messageId}-user-text-${index}`} content={part.text} messageId={messageId} onUpdateMessage={onUpdateMessage} /> ) case 'file': return ( <UserFileSection key={`${messageId}-user-file-${index}`} file={{ name: part.filename || 'Unknown file', url: part.url, contentType: part.mediaType }} /> ) default: return null } })} </> ) } // New rendering: interleave text parts with grouped non-text segments const elements: React.ReactNode[] = [] let buffer: any[] = [] const flushBuffer = (keySuffix: string) => { if (buffer.length === 0) return elements.push( <ResearchProcessSection key={`${messageId}-proc-${keySuffix}`} message={message} messageId={messageId} parts={buffer} getIsOpen={getIsOpen} onOpenChange={onOpenChange} onQuerySelect={onQuerySelect} status={status} addToolResult={addToolResult} /> ) buffer = [] } message.parts?.forEach((part: any, index: number) => { if (part.type === 'text') { // Check if there's buffered content before this text part const hasBufferedContent = buffer.length > 0 // Flush accumulated non-text first, marking that text follows if (hasBufferedContent) { // Create a custom flush that passes hasSubsequentText if (buffer.length > 0) { elements.push( <ResearchProcessSection key={`${messageId}-proc-seg-${index}`} message={message} messageId={messageId} parts={buffer} getIsOpen={getIsOpen} onOpenChange={onOpenChange} onQuerySelect={onQuerySelect} status={status} addToolResult={addToolResult} hasSubsequentText={true} /> ) buffer = [] } } const remainingParts = message.parts?.slice(index + 1) || [] const hasMoreTextParts = remainingParts.some(p => p.type === 'text') const isLastTextPart = !hasMoreTextParts const isStreamingComplete = status !== 'streaming' && status !== 'submitted' const shouldShowActions = isLastTextPart && (isLatestMessage ? isStreamingComplete : true) elements.push( <AnswerSection key={`${messageId}-text-${index}`} content={part.text} isOpen={getIsOpen( messageId, part.type, index < (message.parts?.length ?? 0) - 1 )} onOpenChange={open => onOpenChange(messageId, open)} chatId={chatId} isGuest={isGuest} showActions={shouldShowActions} messageId={messageId} metadata={message.metadata as UIMessageMetadata | undefined} reload={reload} status={status} citationMaps={citationMaps} /> ) } else if ( part.type === 'reasoning' || part.type?.startsWith?.('tool-') || part.type?.startsWith?.('data-') ) { buffer.push(part) } else if (part.type === 'dynamic-tool') { flushBuffer(`seg-${index}`) elements.push( <DynamicToolDisplay key={`${messageId}-dynamic-tool-${index}`} part={part as DynamicToolPart} /> ) } }) // Flush tail (no subsequent text) flushBuffer('tail') return <>{elements}</> } ================================================ FILE: components/research-process-section.tsx ================================================ 'use client' import { useCallback, useState } from 'react' import type { ReasoningPart } from '@ai-sdk/provider-utils' import { UseChatHelpers } from '@ai-sdk/react' import { ChevronDown } from 'lucide-react' import type { DataPart as UIDataPart, ToolPart, UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import type { DynamicToolPart } from '@/lib/types/dynamic-tools' import { cn } from '@/lib/utils' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible' import { DataSection } from './data-section' import { ReasoningSection } from './reasoning-section' import { ToolSection } from './tool-section' // Message part types type TextPart = { type: 'text' text: string } type DataPart = UIDataPart type MessagePart = | ReasoningPart | ToolPart | TextPart | DataPart | DynamicToolPart // Type guards function isReasoningPart(part: MessagePart): part is ReasoningPart { return part.type === 'reasoning' } function isToolPart(part: MessagePart): part is ToolPart { return ( (part.type?.startsWith?.('tool-') && part.type !== 'dynamic-tool') ?? false ) } function isTextPart(part: MessagePart): part is TextPart { return part.type === 'text' } function isDataPart(part: MessagePart): part is DataPart { return part.type?.startsWith?.('data-') ?? false } type Props = { message: UIMessage messageId: string getIsOpen: (id: string, partType?: string, hasNextPart?: boolean) => boolean onOpenChange: (id: string, open: boolean) => void onQuerySelect: (query: string) => void status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] addToolResult?: (params: { toolCallId: string; result: any }) => void parts?: MessagePart[] hasSubsequentText?: boolean } /** * Splits message parts into segments, where each segment contains * non-text parts between text parts * @param parts - Array of message parts to split * @returns Array of segments (arrays of non-text parts) */ function splitByText(parts: MessagePart[]): MessagePart[][] { const segments: MessagePart[][] = [] let currentSegment: MessagePart[] = [] for (const part of parts || []) { if (isTextPart(part)) { // When we hit a text part, save the current segment if it has content if (currentSegment.length > 0) { segments.push(currentSegment) currentSegment = [] } } else { // Accumulate non-text parts currentSegment.push(part) } } // Don't forget the last segment if (currentSegment.length > 0) { segments.push(currentSegment) } return segments } /** * Groups consecutive tool parts of the same type together * @param segment - Array of message parts within a segment * @returns Array of grouped parts */ function groupConsecutiveParts(segment: MessagePart[]): MessagePart[][] { if (segment.length === 0) return [] const groups: MessagePart[][] = [] let currentIndex = 0 while (currentIndex < segment.length) { const currentPart = segment[currentIndex] if (isToolPart(currentPart)) { // Group consecutive tool parts of the same type const toolGroup = [currentPart] const toolType = currentPart.type let nextIndex = currentIndex + 1 while ( nextIndex < segment.length && segment[nextIndex].type === toolType ) { toolGroup.push(segment[nextIndex] as ToolPart) nextIndex++ } groups.push(toolGroup) currentIndex = nextIndex } else { // Non-tool parts stay as single-item groups groups.push([currentPart]) currentIndex++ } } return groups } /** * Custom hook for managing accordion state in grouped sections */ function useAccordionState(onOpenChange: (id: string, open: boolean) => void) { const [openSectionId, setOpenSectionId] = useState<string | null>(null) const handleAccordionChange = useCallback( (id: string, open: boolean, isSingle: boolean) => { if (isSingle) { // For single sections, use the original behavior onOpenChange(id, open) } else { // For grouped sections, implement accordion behavior if (open) { setOpenSectionId(id) } else { setOpenSectionId(null) } // Still notify parent for tracking purposes onOpenChange(id, open) } }, [onOpenChange] ) return { openSectionId, handleAccordionChange } } /** * Renders a single part (reasoning, tool, or data) */ function RenderPart({ part, partId, hasNext, hasSubsequentContent, isSingle, isFirstGroup, isLastGroup, groupLength, partIndex, getIsOpen, openSectionId, handleAccordionChange, status, addToolResult, onQuerySelect }: { part: MessagePart partId: string hasNext: boolean hasSubsequentContent: boolean isSingle: boolean isFirstGroup: boolean isLastGroup: boolean groupLength: number partIndex: number getIsOpen: (id: string, partType?: string, hasNextPart?: boolean) => boolean openSectionId: string | null handleAccordionChange: (id: string, open: boolean, isSingle: boolean) => void status?: any addToolResult?: (params: { toolCallId: string; result: any }) => void onQuerySelect: (query: string) => void }) { const hasSubsequent = hasNext || hasSubsequentContent if (isReasoningPart(part)) { const isOpen = isSingle ? getIsOpen(partId, 'reasoning', hasSubsequent) : openSectionId === partId return ( <ReasoningSection content={{ reasoning: part.text, isDone: !hasNext }} isOpen={isOpen} onOpenChange={open => handleAccordionChange(partId, open, isSingle)} isSingle={isSingle} isFirst={isFirstGroup && partIndex === 0} isLast={isLastGroup && partIndex === groupLength - 1} /> ) } if (isToolPart(part)) { const isOpen = isSingle ? getIsOpen(part.toolCallId, part.type, hasSubsequent) : openSectionId === part.toolCallId return ( <ToolSection tool={part} isOpen={isOpen} onOpenChange={open => handleAccordionChange(part.toolCallId, open, isSingle) } status={status} addToolResult={addToolResult} onQuerySelect={onQuerySelect} borderless={!isSingle} isFirst={isFirstGroup && partIndex === 0} isLast={isLastGroup && partIndex === groupLength - 1} /> ) } if (isDataPart(part)) { return <DataSection part={part} onQuerySelect={onQuerySelect} /> } return null } /** * Determines if there's content after a given segment * @param segmentIndex - The index of the current segment * @param segments - All segments * @param messageParts - Original message parts * @returns true if there's subsequent content */ function useHasSubsequentContent( segments: MessagePart[][], messageParts: MessagePart[] | undefined ) { return useCallback( (segmentIndex: number): boolean => { // Check if there are more segments after this one if (segmentIndex < segments.length - 1) { return true } // Check if there are text parts after the last segment in the original message parts const lastSegment = segments[segmentIndex] if (!lastSegment || lastSegment.length === 0) { return false } const lastPartInSegment = lastSegment[lastSegment.length - 1] const remainingParts = messageParts?.slice( messageParts.findIndex(p => p === lastPartInSegment) + 1 ) || [] return remainingParts.some(p => isTextPart(p)) }, [segments, messageParts] ) } export function ResearchProcessSection({ message, messageId, getIsOpen, onOpenChange, onQuerySelect, status, addToolResult, parts: partsOverride, hasSubsequentText = false }: Props) { const allParts = (partsOverride ?? (message.parts || [])) as MessagePart[] // Filter out empty reasoning parts to avoid incorrect grouping const filteredParts = allParts.filter(p => !(isReasoningPart(p) && !p.text)) const segments = partsOverride ? [filteredParts] : splitByText(filteredParts) // Use custom hook for accordion state management const { openSectionId, handleAccordionChange } = useAccordionState(onOpenChange) // Use custom hook for subsequent content detection const hasSubsequentContent = useHasSubsequentContent( segments, message.parts as MessagePart[] | undefined ) // State for parent collapsible (when segment has 5+ parts) // Auto-collapse when text generation starts (hasSubsequentText is true) const [parentOpenStates, setParentOpenStates] = useState< Record<string, boolean> >({}) if (segments.length === 0 || segments.every(seg => seg.length === 0)) return null return ( <div className="space-y-2"> {segments.map((seg, sidx) => { const groups = groupConsecutiveParts(seg) const isSingle = groups.length === 1 && groups[0].length === 1 const containerClass = cn(!isSingle && 'rounded-lg border bg-card') // Count total parts in this segment const totalParts = seg.length const needsParentCollapsible = totalParts >= 5 // Parent collapsible ID const parentId = `${messageId}-parent-${sidx}` // If user has explicitly set state, use that; otherwise auto-collapse when text follows const isParentOpen = parentOpenStates[parentId] ?? (hasSubsequentText ? false : true) const segmentContent = ( <div className={containerClass}> {groups.map((grp, gidx) => ( <div key={`${messageId}-grp-${sidx}-${gidx}`}> {grp.map((part, pidx) => { const partId = isToolPart(part) ? part.toolCallId : `${messageId}-${part.type}-${sidx}-${gidx}-${pidx}` return ( <RenderPart key={partId} part={part} partId={partId} hasNext={pidx < grp.length - 1} hasSubsequentContent={hasSubsequentContent(sidx)} isSingle={isSingle} isFirstGroup={gidx === 0} isLastGroup={gidx === groups.length - 1} groupLength={grp.length} partIndex={pidx} getIsOpen={getIsOpen} openSectionId={openSectionId} handleAccordionChange={handleAccordionChange} status={status} addToolResult={addToolResult} onQuerySelect={onQuerySelect} /> ) })} </div> ))} </div> ) if (needsParentCollapsible) { return ( <Collapsible key={`${messageId}-seg-${sidx}`} open={isParentOpen} onOpenChange={open => { setParentOpenStates(prev => ({ ...prev, [parentId]: open })) }} > <CollapsibleTrigger asChild> <button type="button" className="flex items-center px-1 py-0.5 gap-2 text-sm rounded-lg group" > <span className="font-medium text-muted-foreground group-hover:text-muted-foreground/70"> Research Process ({totalParts} steps) </span> <ChevronDown className={cn( 'h-4 w-4 text-muted-foreground group-hover:text-muted-foreground/70 transition-transform duration-200', isParentOpen && 'rotate-180' )} /> </button> </CollapsibleTrigger> <CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down"> <div className="pt-2">{segmentContent}</div> </CollapsibleContent> </Collapsible> ) } return <div key={`${messageId}-seg-${sidx}`}>{segmentContent}</div> })} </div> ) } export default ResearchProcessSection ================================================ FILE: components/retry-button.tsx ================================================ 'use client' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' interface RetryButtonProps { reload: () => Promise<void | string | null | undefined> messageId: string } export const RetryButton: React.FC<RetryButtonProps> = ({ reload, messageId }) => { return ( <Button className="rounded-full h-8 w-8" type="button" variant="ghost" size="icon" onClick={() => reload()} aria-label={`Retry from message ${messageId}`} > <RotateCcw className="w-4 h-4" /> <span className="sr-only">Retry</span> </Button> ) } ================================================ FILE: components/search-mode-selector.tsx ================================================ 'use client' import { useEffect, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { SEARCH_MODE_CONFIGS } from '@/lib/config/search-modes' import { SearchMode } from '@/lib/types/search' import { cn } from '@/lib/utils' import { getCookie, setCookie } from '@/lib/utils/cookies' import { Button } from './ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu' import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card' export function SearchModeSelector() { const [value, setValue] = useState<SearchMode>('quick') const [indicatorStyle, setIndicatorStyle] = useState<React.CSSProperties>({}) const [openHoverCard, setOpenHoverCard] = useState<string | null>(null) const [justSelected, setJustSelected] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]) useEffect(() => { const savedMode = getCookie('searchMode') if (savedMode && ['quick', 'adaptive'].includes(savedMode)) { setValue(savedMode as SearchMode) } else if (savedMode) { // Clean up invalid cookie value (e.g., old 'planning' mode) setCookie('searchMode', 'quick') setValue('quick') } }, []) useEffect(() => { // Update indicator position when value changes const selectedIndex = SEARCH_MODE_CONFIGS.findIndex( config => config.value === value ) const selectedButton = buttonsRef.current[selectedIndex] if (selectedButton) { const { offsetLeft, offsetWidth } = selectedButton setIndicatorStyle({ transform: `translateX(${offsetLeft}px)`, width: `${offsetWidth}px` }) } }, [value]) const handleModeSelect = (mode: SearchMode) => { setValue(mode) setCookie('searchMode', mode) setOpenHoverCard(null) // Close hover card on selection setDropdownOpen(false) // Close dropdown on selection setJustSelected(true) // Prevent hover card from reopening immediately setTimeout(() => { setJustSelected(false) }, 500) } const selectedMode = SEARCH_MODE_CONFIGS.find( config => config.value === value ) const SelectedIcon = selectedMode?.icon return ( <> {/* Mobile Dropdown */} <div className="sm:hidden"> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenuTrigger asChild> <Button variant="outline" className="text-sm rounded-full shadow-none gap-1 transition-all" > {SelectedIcon && ( <SelectedIcon className={cn( 'h-4 w-4 transition-colors', selectedMode?.color )} /> )} <span className="text-xs font-medium">{selectedMode?.label}</span> <ChevronDown className={cn( 'h-3 w-3 ml-1 opacity-50 transition-transform duration-200', dropdownOpen && 'rotate-180' )} /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-64" sideOffset={5}> {SEARCH_MODE_CONFIGS.map(config => { const ModeIcon = config.icon const isSelected = value === config.value return ( <DropdownMenuItem key={config.value} onClick={() => handleModeSelect(config.value)} className="relative flex flex-col items-start gap-1 py-2 pl-8 pr-2 cursor-pointer focus:outline-none" > {isSelected && ( <Check className="absolute left-2 top-2.5 h-4 w-4" /> )} <div className="flex items-center gap-2"> <ModeIcon className={cn('h-4 w-4 transition-colors', config.color)} /> <span className="text-sm font-medium">{config.label}</span> </div> <div className="flex flex-col gap-0.5 ml-6"> <span className="text-xs text-muted-foreground"> {config.description} </span> </div> </DropdownMenuItem> ) })} </DropdownMenuContent> </DropdownMenu> </div> {/* Desktop Toggle */} <div className="hidden sm:block"> <div className="relative inline-flex items-center rounded-full bg-background border p-1"> {/* Animated background indicator */} <div className="absolute inset-y-1 rounded-full bg-muted transition-all duration-200 ease-out" style={indicatorStyle} /> {/* Mode buttons */} <div className="relative flex items-center"> {SEARCH_MODE_CONFIGS.map((config, index) => { const Icon = config.icon const isSelected = value === config.value return ( <HoverCard key={config.value} open={!justSelected && openHoverCard === config.value} onOpenChange={open => { if (!justSelected) { setOpenHoverCard(open ? config.value : null) } }} openDelay={100} closeDelay={50} > <HoverCardTrigger asChild> <button type="button" ref={el => { buttonsRef.current[index] = el }} onClick={() => handleModeSelect(config.value)} className={cn( 'relative z-10 flex items-center justify-center rounded-full px-3 py-2 transition-colors duration-200', isSelected ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80' )} aria-label={`${config.label} mode`} aria-pressed={isSelected} > <Icon className={cn( 'h-3.5 w-3.5 transition-colors', isSelected ? config.color : '' )} /> </button> </HoverCardTrigger> <HoverCardContent className="w-72" align="center" sideOffset={8} > <div className="space-y-2"> <div className="flex items-center gap-2"> <Icon className={cn('h-5 w-5', config.color)} /> <h4 className="text-sm font-semibold"> {config.label} </h4> </div> <p className="text-xs text-muted-foreground leading-tight"> {config.description} </p> </div> </HoverCardContent> </HoverCard> ) })} </div> </div> </div> </> ) } ================================================ FILE: components/search-results-image.tsx ================================================ /* eslint-disable @next/next/no-img-element */ 'use client' import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SearchResultImage } from '@/lib/types' import { Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' interface SearchResultsImageSectionProps { images: SearchResultImage[] query?: string displayMode?: 'preview' | 'full' } type NormalizedImage = { id: string; url: string; description: string } type FilterStatus = 'loading' | 'ready' | 'empty' interface FilteredImagesState { status: FilterStatus images: NormalizedImage[] } const normalizeImages = (images: SearchResultImage[]): NormalizedImage[] => { if (!images || images.length === 0) { return [] } return images.map((image, index) => { if (typeof image === 'string') { return { id: `${index}-${image}`, url: image, description: '' } } const url = image.url ?? '' return { id: `${index}-${url}`, url, description: image.description ?? '' } }) } const useFilteredImages = (images: SearchResultImage[]) => { const normalizedImages = useMemo(() => normalizeImages(images), [images]) const previousIdsRef = useRef<string | null>(null) const [state, setState] = useState<FilteredImagesState>(() => ({ status: normalizedImages.length === 0 ? 'empty' : 'loading', images: [] })) useEffect(() => { const normalizedKey = normalizedImages.map(image => image.id).join('|') if (normalizedImages.length === 0) { setState({ status: 'empty', images: [] }) previousIdsRef.current = normalizedKey return } if (typeof window === 'undefined') { setState({ status: 'ready', images: normalizedImages }) previousIdsRef.current = normalizedKey return } if (previousIdsRef.current === normalizedKey) { setState(prevState => { if (prevState.status === 'ready') { return prevState } return { status: 'ready', images: prevState.images.length > 0 ? prevState.images : normalizedImages } }) return } previousIdsRef.current = normalizedKey let cancelled = false setState({ status: 'loading', images: [] }) const preloadImage = (image: NormalizedImage) => new Promise<NormalizedImage | null>(resolve => { if (!image.url) { resolve(null) return } const img = new window.Image() img.onload = () => resolve(image) img.onerror = () => resolve(null) img.src = image.url }) Promise.all(normalizedImages.map(preloadImage)).then(results => { if (cancelled) { return } const validImages = results.filter(Boolean) as NormalizedImage[] if (validImages.length === 0) { setState({ status: 'empty', images: [] }) return } setState({ status: 'ready', images: validImages }) }) return () => { cancelled = true } }, [normalizedImages]) const removeImage = useCallback((id: string) => { setState(prevState => { if (prevState.status === 'loading') { return prevState } const remaining = prevState.images.filter(image => image.id !== id) return { status: remaining.length === 0 ? 'empty' : 'ready', images: remaining } }) }, []) const displayImages = state.status === 'loading' ? normalizedImages : state.images return { status: state.status, filteredImages: state.images, displayImages, removeImage } } const useCarouselMetrics = ({ api, imageCount, selectedIndex, setSelectedIndex }: { api: CarouselApi | undefined imageCount: number selectedIndex: number setSelectedIndex: Dispatch<SetStateAction<number>> }) => { const [current, setCurrent] = useState(() => imageCount > 0 ? Math.min(selectedIndex + 1, imageCount) : 0 ) useEffect(() => { if (!api) { if (imageCount === 0) { setCurrent(0) } else { setCurrent(Math.min(selectedIndex + 1, imageCount)) } return } const handleSelect = () => { setCurrent(api.selectedScrollSnap() + 1) } handleSelect() api.on('select', handleSelect) return () => { api.off('select', handleSelect) } }, [api, imageCount, selectedIndex]) useEffect(() => { setSelectedIndex(prevIndex => { if (imageCount === 0) { return 0 } return Math.min(prevIndex, imageCount - 1) }) }, [imageCount, setSelectedIndex]) useEffect(() => { if (!api || imageCount === 0) { if (imageCount === 0) { setCurrent(0) } return } const clampedIndex = Math.min(selectedIndex, imageCount - 1) api.scrollTo(clampedIndex, false) }, [api, selectedIndex, imageCount]) return { current } } const cornerClassForIndex = (actualIndex: number, isFullMode: boolean) => { if (!isFullMode) { return 'rounded-lg' } if (actualIndex === 0) return 'rounded-tl-lg' if (actualIndex === 1) return 'rounded-tr-lg' if (actualIndex === 2) return 'rounded-bl-lg' if (actualIndex === 4) return 'rounded-br-lg' return '' } export const SearchResultsImageSection: React.FC< SearchResultsImageSectionProps > = ({ images, query, displayMode = 'preview' }) => { const [api, setApi] = useState<CarouselApi>() const [selectedIndex, setSelectedIndex] = useState(0) const { status, filteredImages, displayImages, removeImage } = useFilteredImages(images) const filteredCount = filteredImages.length const isLoading = status === 'loading' const { current } = useCarouselMetrics({ api, imageCount: filteredCount, selectedIndex, setSelectedIndex }) if (status === 'empty') { return <div className="text-muted-foreground">No images found</div> } const handleSelect = (index: number) => { if (!isLoading) { setSelectedIndex(index) } } const renderImageGrid = ( imageSubset: NormalizedImage[], gridClasses: string, startIndex: number = 0, isFullMode: boolean = false ) => ( <div className={gridClasses}> {imageSubset.map((image, index) => { const actualIndex = startIndex + index const cornerClasses = cornerClassForIndex(actualIndex, isFullMode) if (isLoading || !image.url) { return ( <div key={image.id} className="aspect-video"> <div className={`h-full w-full bg-muted animate-pulse shadow-sm ${cornerClasses}`} /> </div> ) } return ( <Dialog key={image.id}> <DialogTrigger asChild> <div className="aspect-video cursor-pointer relative" onClick={() => handleSelect(actualIndex)} > <div className="flex-1 h-full"> <div className="h-full w-full"> <img src={image.url} alt={`Image ${actualIndex + 1}`} className={`h-full w-full object-cover shadow-sm ${cornerClasses}`} onError={() => removeImage(image.id)} /> </div> </div> </div> </DialogTrigger> <DialogContent className="sm:max-w-3xl max-h-[80vh] overflow-auto"> <DialogHeader> <DialogTitle>Search Images</DialogTitle> <DialogDescription className="text-sm"> {query} </DialogDescription> </DialogHeader> <div className="py-4"> <Carousel setApi={setApi} opts={{ startIndex: selectedIndex, loop: filteredCount > 1 }} className="w-full bg-muted max-h-[60vh]" > <CarouselContent> {filteredImages.map((img, idx) => ( <CarouselItem key={img.id}> <div className="p-1 flex items-center justify-center h-full"> <img src={img.url} alt={`Image ${idx + 1}`} className="h-auto w-full object-contain max-h-[60vh]" onError={() => removeImage(img.id)} /> </div> </CarouselItem> ))} </CarouselContent> {filteredCount > 1 && ( <div className="absolute inset-8 flex items-center justify-between p-4"> <CarouselPrevious className="w-10 h-10 rounded-full shadow-sm focus:outline-hidden"> <span className="sr-only">Previous</span> </CarouselPrevious> <CarouselNext className="w-10 h-10 rounded-full shadow-sm focus:outline-hidden"> <span className="sr-only">Next</span> </CarouselNext> </div> )} </Carousel> <div className="py-2 text-center text-sm text-muted-foreground"> {current} of {filteredCount} </div> </div> </DialogContent> </Dialog> ) })} </div> ) if (displayMode === 'full') { const firstRowImages = displayImages.slice(0, 2) const secondRowImages = displayImages.slice(2, 5) return ( <div className="flex flex-col gap-2"> {renderImageGrid(firstRowImages, 'grid grid-cols-2 gap-2', 0, true)} {secondRowImages.length > 0 && renderImageGrid(secondRowImages, 'grid grid-cols-3 gap-2', 2, true)} </div> ) } const previewImages = displayImages.slice(0, 4) return renderImageGrid(previewImages, 'grid grid-cols-2 md:grid-cols-4 gap-2') } ================================================ FILE: components/search-results.tsx ================================================ 'use client' import { useState } from 'react' import Link from 'next/link' import { SearchResultItem } from '@/lib/types' import { displayUrlName } from '@/lib/utils/domain' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' export interface SearchResultsProps { results: SearchResultItem[] displayMode?: 'grid' | 'list' } export function SearchResults({ results, displayMode = 'grid' }: SearchResultsProps) { // State to manage whether to display the results const [showAllResults, setShowAllResults] = useState(false) const handleViewMore = () => { setShowAllResults(true) } // Logic for grid mode const displayedGridResults = showAllResults ? results : results.slice(0, 3) const additionalResultsCount = results.length > 3 ? results.length - 3 : 0 // --- List Mode Rendering --- if (displayMode === 'list') { return ( <div className="flex flex-col gap-2"> {results.map((result, index) => ( <Link href={result.url} key={index} passHref target="_blank" className="block" > <Card className="w-full hover:bg-muted/50 transition-colors"> <CardContent className="p-2 flex items-start space-x-2"> <Avatar className="h-4 w-4 mt-1 shrink-0"> <AvatarImage src={`https://www.google.com/s2/favicons?domain=${ new URL(result.url).hostname }`} alt={new URL(result.url).hostname} /> <AvatarFallback className="text-xs"> {new URL(result.url).hostname[0]} </AvatarFallback> </Avatar> <div className="grow overflow-hidden space-y-0.5"> <p className="text-sm font-medium line-clamp-1"> {result.title || new URL(result.url).pathname} </p> <p className="text-xs text-muted-foreground line-clamp-2"> {result.content} </p> <div className="text-xs text-muted-foreground/80 mt-1 truncate"> <span className="underline"> {new URL(result.url).hostname} </span> </div> </div> </CardContent> </Card> </Link> ))} </div> ) } // --- Grid Mode Rendering (Existing Logic) --- return ( <div className="flex flex-wrap -m-1"> {displayedGridResults.map((result, index) => ( <div className="w-1/2 md:w-1/4 p-1 min-w-0" key={index}> <Link href={result.url} passHref target="_blank"> <Card className="flex-1 h-full hover:bg-muted/50 transition-colors"> <CardContent className="p-2 flex flex-col justify-between h-full min-w-0"> <p className="text-xs line-clamp-2 min-h-8"> {result.title || result.content} </p> <div className="mt-2 flex items-center space-x-1 min-w-0"> <Avatar className="h-4 w-4 shrink-0"> <AvatarImage src={`https://www.google.com/s2/favicons?domain=${ new URL(result.url).hostname }`} alt={new URL(result.url).hostname} /> <AvatarFallback> {new URL(result.url).hostname[0]} </AvatarFallback> </Avatar> <div className="text-xs opacity-60 truncate min-w-0"> {displayUrlName(result.url)} </div> </div> </CardContent> </Card> </Link> </div> ))} {!showAllResults && additionalResultsCount > 0 && ( <div className="w-1/2 md:w-1/4 p-1"> <Card className="flex-1 flex h-full items-center justify-center"> <CardContent className="p-2"> <Button variant={'link'} className="text-muted-foreground" onClick={handleViewMore} > View {additionalResultsCount} more </Button> </CardContent> </Card> </div> )} </div> ) } ================================================ FILE: components/search-section.tsx ================================================ 'use client' import { UseChatHelpers } from '@ai-sdk/react' import { Check, Search as SearchIcon } from 'lucide-react' import type { SearchResults as TypeSearchResults } from '@/lib/types' import type { ToolPart, UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import { cn } from '@/lib/utils' import { useArtifact } from '@/components/artifact/artifact-context' import { StatusIndicator } from './ui/status-indicator' import { CollapsibleMessage } from './collapsible-message' import { SearchSkeleton } from './default-skeleton' import ProcessHeader from './process-header' import { SearchResults } from './search-results' import { SearchResultsImageSection } from './search-results-image' import { Section } from './section' import { SourceFavicons } from './source-favicons' import { createVideoSearchResults, VideoSearchResults } from './video-search-results' interface SearchSectionProps { tool: ToolPart<'search'> isOpen: boolean onOpenChange: (open: boolean) => void status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] borderless?: boolean isFirst?: boolean isLast?: boolean } export function SearchSection({ tool, isOpen, onOpenChange, status, borderless, isFirst = false, isLast = false }: SearchSectionProps) { const isLoading = status === 'submitted' || status === 'streaming' const isToolLoading = tool.state === 'input-streaming' || tool.state === 'input-available' // Handle streaming output states const output = tool.state === 'output-available' ? tool.output : undefined const isSearching = output?.state === 'searching' const searchResults: TypeSearchResults | undefined = output?.state === 'complete' ? output : undefined const isError = tool.state === 'output-error' const errorMessage = tool.errorText || 'Search failed' const query = tool.input?.query || output?.query || '' const includeDomains = tool.input?.include_domains const includeDomainsString = includeDomains && includeDomains.length > 0 ? ` [${includeDomains.join(', ')}]` : '' const { open } = useArtifact() const totalResults = (searchResults?.results?.length || 0) + (searchResults?.videos?.length || 0) + (searchResults?.images?.length || 0) const header = ( <ProcessHeader onInspect={() => open(tool)} isLoading={isLoading && (isToolLoading || isSearching)} ariaExpanded={isOpen} label={ <div className="flex items-center gap-2 min-w-0 overflow-hidden"> <SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <span className="truncate block min-w-0 max-w-full">{`${query}${includeDomainsString}`}</span> </div> } meta={ searchResults && totalResults > 0 ? ( <div className="flex items-center gap-2"> <StatusIndicator icon={Check} iconClassName="text-green-500"> {totalResults} results </StatusIndicator> {searchResults.results && searchResults.results.length > 0 && ( <SourceFavicons results={searchResults.results} maxDisplay={3} /> )} </div> ) : undefined } /> ) return ( <div className="relative"> {/* Rails for header - show based on position */} {borderless && ( <> {!isFirst && ( <div className="absolute left-[19.5px] w-px bg-border h-2 top-0" /> )} {!isLast && ( <div className="absolute left-[19.5px] w-px bg-border h-2 bottom-0" /> )} </> )} <CollapsibleMessage role="assistant" isCollapsible={true} header={header} isOpen={isOpen} onOpenChange={onOpenChange} showIcon={false} showBorder={!borderless} variant="default" showSeparator={false} headerClickBehavior="split" > <div className="flex"> {/* Rail space - always reserved when grouped */} {borderless && ( <> <div className="w-[16px] shrink-0 flex justify-center"> <div className={cn( 'w-px bg-border/50 transition-opacity duration-200', isOpen ? 'opacity-100' : 'opacity-0' )} style={{ marginTop: isFirst ? '0' : '-1rem', marginBottom: isLast ? '0' : '-1rem' }} /> </div> <div className="w-2 shrink-0" /> </> )} <div className="flex-1"> {searchResults && searchResults.images && searchResults.images.length > 0 && ( <Section> <SearchResultsImageSection images={searchResults.images} query={query} /> </Section> )} {searchResults && searchResults.videos && searchResults.videos.length > 0 && ( <Section title="Videos"> <VideoSearchResults results={createVideoSearchResults(searchResults, query)} /> </Section> )} {isError ? ( <Section> <div className="bg-card rounded-lg"> <div className="flex items-center gap-2 w-full"> <span className="text-sm text-destructive block flex-1 min-w-0"> {errorMessage} </span> </div> </div> </Section> ) : (isLoading && isToolLoading) || isSearching ? ( <SearchSkeleton /> ) : searchResults?.results && searchResults.results.length > 0 ? ( <Section title="Sources"> <SearchResults results={searchResults.results} /> </Section> ) : null} </div> </div> </CollapsibleMessage> </div> ) } ================================================ FILE: components/section.tsx ================================================ 'use client' import React from 'react' import { BookCheck, Check, File, FileText, Film, Image, MessageCircleMore, Repeat2, Search } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from './ui/badge' import { Separator } from './ui/separator' import { StatusIndicator } from './ui/status-indicator' import { ToolBadge } from './tool-badge' type SectionProps = { children: React.ReactNode className?: string size?: 'sm' | 'md' | 'lg' title?: string separator?: boolean } export const Section: React.FC<SectionProps> = ({ children, className, size = 'md', title, separator = false }) => { const iconSize = 16 const iconClassName = 'mr-1.5 text-muted-foreground' let icon: React.ReactNode let type: 'text' | 'badge' = 'text' switch (title) { case 'Images': // eslint-disable-next-line jsx-a11y/alt-text icon = <Image size={iconSize} className={iconClassName} /> break case 'Videos': icon = <Film size={iconSize} className={iconClassName} /> type = 'badge' break case 'Sources': icon = <FileText size={iconSize} className={iconClassName} /> type = 'badge' break case 'Answer': icon = <BookCheck size={iconSize} className={iconClassName} /> break case 'Related': icon = <Repeat2 size={iconSize} className={iconClassName} /> break case 'Follow-up': icon = <MessageCircleMore size={iconSize} className={iconClassName} /> break case 'Content': icon = <File size={iconSize} className={iconClassName} /> type = 'badge' break default: icon = <Search size={iconSize} className={iconClassName} /> } return ( <> {separator && <Separator className="my-2 bg-primary/10" />} <section className={cn( ` ${size === 'sm' ? 'py-1' : size === 'lg' ? 'py-4' : 'py-2'}`, className )} > {title && type === 'text' && ( <h2 className="flex items-center leading-none py-2"> {icon} {title} </h2> )} {title && type === 'badge' && ( <Badge variant="secondary" className="mb-2"> {icon} {title} </Badge> )} {children} </section> </> ) } export function ToolArgsSection({ children, tool, number, isLoading }: { children: React.ReactNode tool: string number?: number isLoading?: boolean }) { return ( <Section size="sm" className="py-0 flex items-center justify-between w-full gap-2 overflow-hidden min-w-0" > <div className="min-w-0 flex-1 overflow-hidden"> <ToolBadge tool={tool} isLoading={isLoading}> {children} </ToolBadge> </div> {number && number > 0 && ( <div className="shrink-0"> <StatusIndicator icon={Check} iconClassName="text-green-500"> {number} results </StatusIndicator> </div> )} </Section> ) } ================================================ FILE: components/sidebar/chat-history-client.tsx ================================================ 'use client' import { useCallback, useEffect, useRef, useState, useTransition } from 'react' import { toast } from 'sonner' import { Chat as DBChat } from '@/lib/db/schema' import { SidebarGroup, SidebarGroupLabel, SidebarMenu } from '@/components/ui/sidebar' import { ChatHistorySkeleton } from './chat-history-skeleton' import { ChatMenuItem } from './chat-menu-item' import { ClearHistoryAction } from './clear-history-action' interface ChatPageResponse { chats: DBChat[] nextOffset: number | null } export function ChatHistoryClient() { const [chats, setChats] = useState<DBChat[]>([]) const [nextOffset, setNextOffset] = useState<number | null>(null) const [isLoading, setIsLoading] = useState(true) const loadMoreRef = useRef<HTMLDivElement>(null) const [isPending, startTransition] = useTransition() const fetchInitialChats = useCallback(async () => { setIsLoading(true) try { const response = await fetch(`/api/chats?offset=0&limit=20`) if (!response.ok) { throw new Error('Failed to fetch initial chat history') } const { chats: dbChats, nextOffset: newNextOffset } = (await response.json()) as ChatPageResponse setChats(dbChats) setNextOffset(newNextOffset) } catch (error) { console.error('Failed to load initial chats:', error) toast.error('Failed to load chat history.') setNextOffset(null) } finally { setIsLoading(false) } }, []) useEffect(() => { fetchInitialChats() }, [fetchInitialChats]) useEffect(() => { const handleHistoryUpdate = () => { startTransition(() => { fetchInitialChats() }) } window.addEventListener('chat-history-updated', handleHistoryUpdate) return () => { window.removeEventListener('chat-history-updated', handleHistoryUpdate) } }, [fetchInitialChats]) const fetchMoreChats = useCallback(async () => { if (isLoading || nextOffset === null) return setIsLoading(true) try { const response = await fetch(`/api/chats?offset=${nextOffset}&limit=20`) if (!response.ok) { throw new Error('Failed to fetch more chat history') } const { chats: dbChats, nextOffset: newNextOffset } = (await response.json()) as ChatPageResponse setChats(prevChats => [...prevChats, ...dbChats]) setNextOffset(newNextOffset) } catch (error) { console.error('Failed to load more chats:', error) toast.error('Failed to load more chat history.') setNextOffset(null) } finally { setIsLoading(false) } }, [nextOffset, isLoading]) useEffect(() => { const observerRefValue = loadMoreRef.current if (!observerRefValue || nextOffset === null || isPending) return const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !isLoading && !isPending) { fetchMoreChats() } }, { threshold: 0.1 } ) observer.observe(observerRefValue) return () => { if (observerRefValue) { observer.unobserve(observerRefValue) } } }, [fetchMoreChats, nextOffset, isLoading, isPending]) const isHistoryEmpty = !isLoading && !chats.length && nextOffset === null return ( <div className="flex flex-col flex-1 h-full"> <SidebarGroup> <div className="flex items-center justify-between w-full"> <SidebarGroupLabel className="p-0">History</SidebarGroupLabel> <ClearHistoryAction empty={isHistoryEmpty} /> </div> </SidebarGroup> <div className="flex-1 overflow-y-auto mb-2 relative"> {isHistoryEmpty && !isPending ? ( <div className="px-2 text-foreground/30 text-sm text-center py-4"> No search history </div> ) : ( <SidebarMenu> {chats.map( (chat: DBChat) => chat && <ChatMenuItem key={chat.id} chat={chat} /> )} </SidebarMenu> )} <div ref={loadMoreRef} style={{ height: '1px' }} /> {(isLoading || isPending) && ( <div className="py-2"> <ChatHistorySkeleton /> </div> )} </div> </div> ) } ================================================ FILE: components/sidebar/chat-history-section.tsx ================================================ import { ChatHistoryClient } from './chat-history-client' export async function ChatHistorySection() { return <ChatHistoryClient /> } ================================================ FILE: components/sidebar/chat-history-skeleton.tsx ================================================ import { SidebarMenu, SidebarMenuItem, SidebarMenuSkeleton } from '@/components/ui/sidebar' export function ChatHistorySkeleton() { return ( <SidebarMenu> {Array.from({ length: 5 }).map((_, idx) => ( <SidebarMenuItem key={idx}> <SidebarMenuSkeleton showIcon={false} /> </SidebarMenuItem> ))} </SidebarMenu> ) } ================================================ FILE: components/sidebar/chat-menu-item.tsx ================================================ 'use client' import { useCallback, useState, useTransition } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { MoreHorizontal, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { deleteChat } from '@/lib/actions/chat' import { Chat as DBChat } from '@/lib/db/schema' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { SidebarMenuAction, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' import { Spinner } from '../ui/spinner' interface ChatMenuItemProps { chat: DBChat } const formatDateWithTime = (date: Date | string) => { const parsedDate = new Date(date) const now = new Date() const yesterday = new Date() yesterday.setDate(yesterday.getDate() - 1) const formatTime = (date: Date) => { return date.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) } if ( parsedDate.getDate() === now.getDate() && parsedDate.getMonth() === now.getMonth() && parsedDate.getFullYear() === now.getFullYear() ) { return `Today, ${formatTime(parsedDate)}` } else if ( parsedDate.getDate() === yesterday.getDate() && parsedDate.getMonth() === yesterday.getMonth() && parsedDate.getFullYear() === yesterday.getFullYear() ) { return `Yesterday, ${formatTime(parsedDate)}` } else { return parsedDate.toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true }) } } export function ChatMenuItem({ chat }: ChatMenuItemProps) { const pathname = usePathname() const path = `/search/${chat.id}` const isActive = pathname === path const router = useRouter() const [isPending, startTransition] = useTransition() const [isMenuOpen, setIsMenuOpen] = useState(false) const [isAlertOpen, setIsAlertOpen] = useState(false) const handleDeleteChat = useCallback(() => { startTransition(async () => { const result = await deleteChat(chat.id) if (result?.success) { toast.success('Chat deleted') if (isActive) { router.push('/') } window.dispatchEvent(new CustomEvent('chat-history-updated')) } else if (result?.error) { toast.error(result.error) } else { toast.error('An unexpected error occurred while deleting the chat.') } setIsAlertOpen(false) setIsMenuOpen(false) }) }, [chat.id, isActive, router, startTransition]) const handleAlertOpenChange = useCallback( (open: boolean) => { setIsAlertOpen(open) if (!open && !isPending) { setIsMenuOpen(false) } }, [isPending, setIsMenuOpen, setIsAlertOpen] ) const handleMenuOpenChange = useCallback( (open: boolean) => { setIsMenuOpen(open) if (!open && !isPending) { setIsAlertOpen(false) } }, [isPending, setIsMenuOpen, setIsAlertOpen] ) return ( <SidebarMenuItem> <SidebarMenuButton asChild isActive={isActive} className="h-auto flex-col gap-0.5 items-start p-2 pr-8" > <Link href={path}> <div className="text-xs font-medium truncate select-none w-full"> {chat.title} </div> <div className="text-xs text-muted-foreground w-full"> {formatDateWithTime(chat.createdAt)} </div> </Link> </SidebarMenuButton> <DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}> <DropdownMenuTrigger asChild> <SidebarMenuAction className="size-7 p-1 mr-1"> <MoreHorizontal size={16} /> <span className="sr-only">Chat Actions</span> </SidebarMenuAction> </DropdownMenuTrigger> <DropdownMenuContent side="right" align="start"> <AlertDialog open={isAlertOpen} onOpenChange={handleAlertOpenChange}> <AlertDialogTrigger asChild> <DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onSelect={e => { e.preventDefault() }} > <Trash2 size={14} /> Delete Chat </DropdownMenuItem> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. This will permanently delete this chat history. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={isPending}> Cancel </AlertDialogCancel> <AlertDialogAction disabled={isPending} onClick={event => { event.preventDefault() handleDeleteChat() }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {isPending ? ( <div className="flex items-center justify-center"> <Spinner /> </div> ) : ( 'Delete' )} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> ) } ================================================ FILE: components/sidebar/clear-history-action.tsx ================================================ 'use client' import { useCallback, useState, useTransition } from 'react' import { useRouter } from 'next/navigation' import { MoreHorizontal, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { clearChats } from '@/lib/actions/chat' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { SidebarGroupAction } from '@/components/ui/sidebar' import { Spinner } from '@/components/ui/spinner' interface ClearHistoryActionProps { empty: boolean } export function ClearHistoryAction({ empty }: ClearHistoryActionProps) { const [isPending, startTransition] = useTransition() const [isMenuOpen, setIsMenuOpen] = useState(false) const [isAlertOpen, setIsAlertOpen] = useState(false) const router = useRouter() const handleClearAction = useCallback(() => { startTransition(async () => { const res = await clearChats() if (res?.success) { toast.success('History cleared') router.push('/') } else if (res?.error) { toast.error(res.error) } setIsAlertOpen(false) setIsMenuOpen(false) window.dispatchEvent(new CustomEvent('chat-history-updated')) }) }, [startTransition, router]) const handleAlertOpenChange = useCallback( (open: boolean) => { setIsAlertOpen(open) if (!open) { setIsMenuOpen(false) } }, [setIsAlertOpen, setIsMenuOpen] ) const handleMenuOpenChange = useCallback( (open: boolean) => { setIsMenuOpen(open) if (!open) { setIsAlertOpen(false) } }, [setIsMenuOpen, setIsAlertOpen] ) return ( <DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}> <DropdownMenuTrigger asChild> <SidebarGroupAction disabled={empty} className="static size-7 p-1"> <MoreHorizontal size={16} /> <span className="sr-only">History Actions</span> </SidebarGroupAction> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <AlertDialog open={isAlertOpen} onOpenChange={handleAlertOpenChange}> <AlertDialogTrigger asChild> <DropdownMenuItem disabled={empty || isPending} className="gap-2 text-destructive focus:text-destructive" onSelect={event => { event.preventDefault() }} > <Trash2 size={14} /> Clear History </DropdownMenuItem> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. It will permanently delete your history. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> <AlertDialogAction disabled={isPending} onClick={event => { event.preventDefault() handleClearAction() }} > {isPending ? <Spinner /> : 'Clear'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </DropdownMenuContent> </DropdownMenu> ) } ================================================ FILE: components/sign-up-form.tsx ================================================ 'use client' import { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils/index' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { IconLogo } from '@/components/ui/icons' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { PasswordInput } from '@/components/ui/password-input' export function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [repeatPassword, setRepeatPassword] = useState('') const [error, setError] = useState<string | null>(null) const [isLoading, setIsLoading] = useState(false) const router = useRouter() const handleSignUp = async (e: React.FormEvent) => { e.preventDefault() const supabase = createClient() setIsLoading(true) setError(null) if (password !== repeatPassword) { setError('Passwords do not match') setIsLoading(false) return } try { const { error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: `${window.location.origin}/` } }) if (error) throw error router.push('/auth/sign-up-success') } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { setIsLoading(false) } } return ( <div className={cn('flex flex-col items-center gap-6', className)} {...props} > <Card className="w-full max-w-sm"> <CardHeader className="text-center"> <CardTitle className="text-2xl flex flex-col items-center justify-center gap-4"> <IconLogo className="size-12" /> Create an account </CardTitle> <CardDescription> Enter your details below to get started </CardDescription> </CardHeader> <CardContent> <form onSubmit={handleSignUp}> <div className="flex flex-col gap-4"> <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="you@example.com" required value={email} onChange={e => setEmail(e.target.value)} /> </div> <div className="grid gap-2"> <div className="flex items-center"> <Label htmlFor="password">Password</Label> </div> <PasswordInput id="password" type="password" placeholder="********" required value={password} onChange={e => setPassword(e.target.value)} /> </div> <div className="grid gap-2"> <div className="flex items-center"> <Label htmlFor="repeat-password">Repeat Password</Label> </div> <PasswordInput id="repeat-password" type="password" placeholder="********" required value={repeatPassword} onChange={e => setRepeatPassword(e.target.value)} /> </div> {error && <p className="text-sm text-red-500">{error}</p>} <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? 'Creating account...' : 'Sign Up'} </Button> </div> <div className="mt-6 text-center text-sm"> Already have an account?{' '} <Link href="/auth/login" className="underline underline-offset-4"> Sign In </Link> </div> </form> </CardContent> </Card> <div className="text-center text-xs text-muted-foreground"> <Link href="/" className="hover:underline"> ← Back to Home </Link> </div> </div> ) } ================================================ FILE: components/source-favicons.tsx ================================================ import Image from 'next/image' import type { SearchResultItem } from '@/lib/types' import { cn } from '@/lib/utils' interface SourceFaviconsProps { results: SearchResultItem[] maxDisplay?: number className?: string } /** * Displays overlapping favicons from search results */ export function SourceFavicons({ results, maxDisplay = 3, className }: SourceFaviconsProps) { // Extract unique domains from results const uniqueDomains = Array.from( new Set( results.map(result => { try { return new URL(result.url).hostname } catch { return null } }) ) ) .filter((domain): domain is string => domain !== null) .slice(0, maxDisplay) if (uniqueDomains.length === 0) { return null } return ( <div className={cn('flex items-center', className)}> {uniqueDomains.map((domain, index) => ( <div key={domain} className="relative rounded-full border border-background overflow-hidden" style={{ marginLeft: index > 0 ? '-6px' : '0', zIndex: uniqueDomains.length - index }} > <Image src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} alt={domain} width={16} height={16} className="bg-background" unoptimized /> </div> ))} </div> ) } ================================================ FILE: components/theme-menu-items.tsx ================================================ 'use client' import { useTheme } from 'next-themes' import { Laptop, Moon, Sun } from 'lucide-react' import { DropdownMenuItem } from '@/components/ui/dropdown-menu' export function ThemeMenuItems() { const { setTheme } = useTheme() return ( <> <DropdownMenuItem onClick={() => setTheme('light')}> <Sun className="mr-2 h-4 w-4" /> <span>Light</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}> <Moon className="mr-2 h-4 w-4" /> <span>Dark</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}> <Laptop className="mr-2 h-4 w-4" /> <span>System</span> </DropdownMenuItem> </> ) } ================================================ FILE: components/theme-provider.tsx ================================================ 'use client' import * as React from 'react' import { ThemeProvider as NextThemesProvider } from 'next-themes' import { type ThemeProviderProps } from 'next-themes/dist/types' export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemesProvider {...props}>{children}</NextThemesProvider> } ================================================ FILE: components/todo-list-content.tsx ================================================ 'use client' import { AlertCircle, Check } from 'lucide-react' import type { TodoItem } from '@/lib/types/ai' import { cn } from '@/lib/utils' export type TodoListContentProps = { todos?: TodoItem[] message?: string summary?: string completedCount?: number totalCount?: number errorText?: string showSummary?: boolean itemVariant?: 'bordered' | 'plain' className?: string } export function TodoListContent({ todos = [], message, summary, completedCount, totalCount, errorText, showSummary = true, itemVariant = 'bordered', className }: TodoListContentProps) { const completed = completedCount ?? todos.filter(t => t.status === 'completed').length const total = totalCount ?? todos.length const getStatusIcon = (status: TodoItem['status']) => { switch (status) { case 'completed': return <Check className="h-4 w-4 text-green-600 flex-shrink-0" /> case 'in_progress': return ( <div className="relative h-4 w-4 flex items-center justify-center flex-shrink-0"> <div className="h-2 w-2 bg-blue-600 rounded-full animate-ping absolute" /> <div className="h-2 w-2 bg-blue-600 rounded-full" /> </div> ) default: return ( <div className="h-4 w-4 flex items-center justify-center flex-shrink-0"> <div className="h-2 w-2 bg-muted-foreground rounded-full" /> </div> ) } } if (errorText) { return ( <div className="flex items-center gap-2 p-4 text-red-600"> <AlertCircle className="size-4" /> <span>Error: {errorText}</span> </div> ) } return ( <div className={cn('space-y-3', className)}> {showSummary && ( <div className="flex items-center justify-between text-sm text-muted-foreground"> <span>{message || summary || 'Todo List'}</span> <span className="font-medium whitespace-nowrap"> ({completed}/{total}) </span> </div> )} <ul className="space-y-2"> {todos.map((todo, index) => ( <li key={todo.id || index}> <div className={cn( 'flex items-center gap-2', itemVariant === 'bordered' ? 'justify-between p-3 rounded-lg border bg-card' : 'py-1' )} > {getStatusIcon(todo.status)} <p className={cn( 'text-sm', todo.status === 'completed' && 'text-muted-foreground' )} > {todo.content} </p> </div> </li> ))} </ul> {todos.length === 0 && ( <div className="text-center py-8 text-muted-foreground"> No todos available </div> )} </div> ) } export default TodoListContent ================================================ FILE: components/tool-badge.tsx ================================================ import React from 'react' import { Link, Search } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from './ui/badge' type ToolBadgeProps = { tool: string children: React.ReactNode className?: string isLoading?: boolean } export const ToolBadge: React.FC<ToolBadgeProps> = ({ tool, children, className, isLoading = false }) => { const icon: Record<string, React.ReactNode> = { search: <Search size={14} />, fetch: <Link size={14} /> } return ( <Badge className={cn( 'inline-flex items-center max-w-full', isLoading && 'animate-pulse', className )} variant={'secondary'} > <span className="shrink-0">{icon[tool]}</span> <span className="ml-1 truncate">{children}</span> </Badge> ) } ================================================ FILE: components/tool-section.tsx ================================================ 'use client' import { UseChatHelpers } from '@ai-sdk/react' import type { ToolPart, UIDataTypes, UIMessage, UITools } from '@/lib/types/ai' import FetchSection from './fetch-section' import { QuestionConfirmation } from './question-confirmation' import { SearchSection } from './search-section' import { ToolTodoDisplay } from './tool-todo-display' interface ToolSectionProps { tool: ToolPart isOpen: boolean onOpenChange: (open: boolean) => void status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status'] addToolResult?: (params: { toolCallId: string; result: any }) => void onQuerySelect: (query: string) => void borderless?: boolean isFirst?: boolean isLast?: boolean } export function ToolSection({ tool, isOpen, onOpenChange, status, addToolResult, onQuerySelect, borderless = false, isFirst = false, isLast = false }: ToolSectionProps) { // Special handling for ask_question tool if (tool.type === 'tool-askQuestion') { // When waiting for user input if ( (tool.state === 'input-streaming' || tool.state === 'input-available') && addToolResult ) { return ( <QuestionConfirmation toolInvocation={tool as ToolPart<'askQuestion'>} onConfirm={(toolCallId, approved, response) => { addToolResult({ toolCallId, result: approved ? response : { declined: true, skipped: response?.skipped, message: 'User declined this question' } }) }} /> ) } // When result is available, display the result if (tool.state === 'output-available') { return ( <QuestionConfirmation toolInvocation={tool as ToolPart<'askQuestion'>} isCompleted={true} onConfirm={() => {}} // Not used in result display mode /> ) } } switch (tool.type) { case 'tool-search': return ( <SearchSection tool={tool as ToolPart<'search'>} isOpen={isOpen} onOpenChange={onOpenChange} status={status} borderless={borderless} isFirst={isFirst} isLast={isLast} /> ) case 'tool-fetch': return ( <FetchSection tool={tool as ToolPart<'fetch'>} isOpen={isOpen} onOpenChange={onOpenChange} status={status} borderless={borderless} isFirst={isFirst} isLast={isLast} /> ) case 'tool-todoWrite': return ( <ToolTodoDisplay tool="todoWrite" state={tool.state} input={tool.input} output={tool.output} errorText={tool.errorText} toolCallId={tool.toolCallId} isOpen={isOpen} onOpenChange={onOpenChange} borderless={borderless} isFirst={isFirst} isLast={isLast} /> ) default: return null } } ================================================ FILE: components/tool-todo-display.tsx ================================================ import { Check, ListTodo } from 'lucide-react' import { Part, TodoItem } from '@/lib/types/ai' import { cn } from '@/lib/utils' import { useArtifact } from './artifact/artifact-context' import { CollapsibleMessage } from './collapsible-message' import ProcessHeader from './process-header' import TodoListContent from './todo-list-content' interface ToolTodoDisplayProps { tool: 'todoWrite' state: | 'input-streaming' | 'input-available' | 'output-available' | 'output-error' input?: { todos?: TodoItem[] } output?: { todos?: TodoItem[] message?: string completedCount?: number totalCount?: number } errorText?: string toolCallId: string isOpen?: boolean onOpenChange?: (open: boolean) => void borderless?: boolean isFirst?: boolean isLast?: boolean } export function ToolTodoDisplay({ tool, state, output, toolCallId, isOpen = false, onOpenChange, borderless = false, isFirst = false, isLast = false }: ToolTodoDisplayProps) { const { open: openArtifact } = useArtifact() // Calculate counts for display const completedCount = output?.completedCount ?? (output?.todos ? output.todos.filter(t => t.status === 'completed').length : 0) const totalCount = output?.totalCount ?? (output?.todos ? output.todos.length : 0) const isLoading = state === 'input-streaming' || state === 'input-available' const openInspector = () => { if (!(state === 'output-available' && output)) return const part: Part = { type: 'tool-todoWrite', toolCallId, state, input: { todos: output.todos || [] }, output } openArtifact(part) } const header = ( <ProcessHeader onInspect={openInspector} isLoading={isLoading} label={ <span className="inline-flex items-center gap-2 min-w-0 overflow-hidden"> <ListTodo className="size-4 text-muted-foreground shrink-0" /> <span className="truncate"> {state === 'output-available' && output ? output.message || 'Updated tasks' : 'Updating tasks...'} </span> </span> } meta={ state === 'output-available' && totalCount > 0 ? ( <span className="flex items-center gap-1 text-muted-foreground"> {completedCount === totalCount ? ( <Check className="size-4 text-green-500" /> ) : null} <span className="text-xs"> ({completedCount}/{totalCount}) </span> </span> ) : undefined } ariaExpanded={isOpen} /> ) return ( <div className="relative"> {/* Rails for header - show based on position */} {borderless && ( <> {!isFirst && ( <div className="absolute left-[19.5px] w-px bg-border h-2 top-0" /> )} {!isLast && ( <div className="absolute left-[19.5px] w-px bg-border h-2 bottom-0" /> )} </> )} <CollapsibleMessage role="assistant" isCollapsible={true} header={header} isOpen={isOpen} onOpenChange={onOpenChange} showBorder={!borderless} showIcon={false} variant="default" showSeparator={false} headerClickBehavior="split" > <div className="flex"> {/* Rail space - always reserved when grouped */} {borderless && ( <> <div className="w-[16px] shrink-0 flex justify-center"> <div className={cn( 'w-px bg-border/50 transition-opacity duration-200', isOpen ? 'opacity-100' : 'opacity-0' )} style={{ marginTop: isFirst ? '0' : '-1rem', marginBottom: isLast ? '0' : '-1rem' }} /> </div> <div className="w-2 shrink-0" /> </> )} <div className="flex-1"> {state === 'output-available' ? ( <TodoListContent todos={output?.todos} message={output?.message} completedCount={completedCount} totalCount={totalCount} showSummary={false} itemVariant="plain" className="pb-1" /> ) : state === 'output-error' ? ( <div className="px-3 pb-3 text-xs text-destructive">{`Todo tool failed${ output && 'message' in output && output.message ? `: ${output.message}` : '' }`}</div> ) : null} </div> </div> </CollapsibleMessage> </div> ) } ================================================ FILE: components/ui/alert-dialog.tsx ================================================ 'use client' import * as React from 'react' import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' const AlertDialog = AlertDialogPrimitive.Root const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogOverlay = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Overlay className={cn( 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} {...props} ref={ref} /> )) AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName const AlertDialogContent = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> >(({ className, ...props }, ref) => ( <AlertDialogPortal> <AlertDialogOverlay /> <AlertDialogPrimitive.Content ref={ref} className={cn( 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', className )} {...props} /> </AlertDialogPortal> )) AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col space-y-2 text-center sm:text-left', className )} {...props} /> ) AlertDialogHeader.displayName = 'AlertDialogHeader' const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className )} {...props} /> ) AlertDialogFooter.displayName = 'AlertDialogFooter' const AlertDialogTitle = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} /> )) AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName const AlertDialogDescription = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )) AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName const AlertDialogAction = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} /> )) AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName const AlertDialogCancel = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Cancel ref={ref} className={cn( buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className )} {...props} /> )) AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger } ================================================ FILE: components/ui/animated-logo.tsx ================================================ 'use client' import { cn } from '@/lib/utils' export function AnimatedLogo({ className, ...props }: React.ComponentProps<'svg'>) { return ( <svg fill="currentColor" viewBox="0 0 256 256" role="img" xmlns="http://www.w3.org/2000/svg" className={cn('h-8 w-8', className)} {...props} > <circle cx="128" cy="128" r="128" fill="black"></circle> <g className="animate-[lookAround_2s_ease-in-out_infinite] origin-center"> <circle cx="102" cy="128" r="18" fill="white"></circle> <circle cx="154" cy="128" r="18" fill="white"></circle> </g> </svg> ) } ================================================ FILE: components/ui/avatar.tsx ================================================ 'use client' import * as React from 'react' import * as AvatarPrimitive from '@radix-ui/react-avatar' import { cn } from '@/lib/utils/index' const Avatar = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Root ref={ref} className={cn( 'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className )} {...props} /> )) Avatar.displayName = AvatarPrimitive.Root.displayName const AvatarImage = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Image>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} /> )) AvatarImage.displayName = AvatarPrimitive.Image.displayName const AvatarFallback = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Fallback ref={ref} className={cn( 'flex h-full w-full items-center justify-center rounded-full bg-muted', className )} {...props} /> )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName export { Avatar, AvatarFallback, AvatarImage } ================================================ FILE: components/ui/badge.tsx ================================================ import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: 'text-foreground' } }, defaultVariants: { variant: 'default' } } ) export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} function Badge({ className, variant, ...props }: BadgeProps) { return ( <div className={cn(badgeVariants({ variant }), className)} {...props} /> ) } export { Badge, badgeVariants } ================================================ FILE: components/ui/button.tsx ================================================ import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils/index' const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline' }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10' } }, defaultVariants: { variant: 'default', size: 'default' } } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = 'Button' export { Button, buttonVariants } ================================================ FILE: components/ui/card.tsx ================================================ import * as React from 'react' import { cn } from '@/lib/utils/index' const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn( 'rounded-lg border bg-card text-card-foreground shadow-xs', className )} {...props} /> )) Card.displayName = 'Card' const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> )) CardHeader.displayName = 'CardHeader' const CardTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => ( <h3 ref={ref} className={cn( 'text-2xl font-semibold leading-none tracking-tight', className )} {...props} /> )) CardTitle.displayName = 'CardTitle' const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => ( <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )) CardDescription.displayName = 'CardDescription' const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> )) CardContent.displayName = 'CardContent' const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> )) CardFooter.displayName = 'CardFooter' export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } ================================================ FILE: components/ui/carousel.tsx ================================================ 'use client' import * as React from 'react' import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react' import { ArrowLeft, ArrowRight } from 'lucide-react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' type CarouselApi = UseEmblaCarouselType[1] type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type CarouselOptions = UseCarouselParameters[0] type CarouselPlugin = UseCarouselParameters[1] type CarouselProps = { opts?: CarouselOptions plugins?: CarouselPlugin orientation?: 'horizontal' | 'vertical' setApi?: (api: CarouselApi) => void } type CarouselContextProps = { carouselRef: ReturnType<typeof useEmblaCarousel>[0] api: ReturnType<typeof useEmblaCarousel>[1] scrollPrev: () => void scrollNext: () => void canScrollPrev: boolean canScrollNext: boolean } & CarouselProps const CarouselContext = React.createContext<CarouselContextProps | null>(null) function useCarousel() { const context = React.useContext(CarouselContext) if (!context) { throw new Error('useCarousel must be used within a <Carousel />') } return context } const Carousel = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps >( ( { orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref ) => { const [carouselRef, api] = useEmblaCarousel( { ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins ) const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false) const onSelect = React.useCallback((api: CarouselApi) => { if (!api) { return } setCanScrollPrev(api.canScrollPrev()) setCanScrollNext(api.canScrollNext()) }, []) const scrollPrev = React.useCallback(() => { api?.scrollPrev() }, [api]) const scrollNext = React.useCallback(() => { api?.scrollNext() }, [api]) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { if (event.key === 'ArrowLeft') { event.preventDefault() scrollPrev() } else if (event.key === 'ArrowRight') { event.preventDefault() scrollNext() } }, [scrollPrev, scrollNext] ) React.useEffect(() => { if (!api || !setApi) { return } setApi(api) }, [api, setApi]) React.useEffect(() => { if (!api) { return } onSelect(api) api.on('reInit', onSelect) api.on('select', onSelect) return () => { api?.off('select', onSelect) } }, [api, onSelect]) return ( <CarouselContext.Provider value={{ carouselRef, api: api, opts, orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'), scrollPrev, scrollNext, canScrollPrev, canScrollNext }} > <div ref={ref} onKeyDownCapture={handleKeyDown} className={cn('relative', className)} role="region" aria-roledescription="carousel" {...props} > {children} </div> </CarouselContext.Provider> ) } ) Carousel.displayName = 'Carousel' const CarouselContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { carouselRef, orientation } = useCarousel() return ( <div ref={carouselRef} className="overflow-hidden"> <div ref={ref} className={cn( 'flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className )} {...props} /> </div> ) }) CarouselContent.displayName = 'CarouselContent' const CarouselItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { orientation } = useCarousel() return ( <div ref={ref} role="group" aria-roledescription="slide" className={cn( 'min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', className )} {...props} /> ) }) CarouselItem.displayName = 'CarouselItem' const CarouselPrevious = React.forwardRef< HTMLButtonElement, React.ComponentProps<typeof Button> >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { const { orientation, scrollPrev, canScrollPrev } = useCarousel() return ( <Button ref={ref} variant={variant} size={size} className={cn( 'absolute h-8 w-8 rounded-full', orientation === 'horizontal' ? '-left-12 top-1/2 -translate-y-1/2' : '-top-12 left-1/2 -translate-x-1/2 rotate-90', className )} disabled={!canScrollPrev} onClick={scrollPrev} {...props} > <ArrowLeft className="h-4 w-4" /> <span className="sr-only">Previous slide</span> </Button> ) }) CarouselPrevious.displayName = 'CarouselPrevious' const CarouselNext = React.forwardRef< HTMLButtonElement, React.ComponentProps<typeof Button> >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { const { orientation, scrollNext, canScrollNext } = useCarousel() return ( <Button ref={ref} variant={variant} size={size} className={cn( 'absolute h-8 w-8 rounded-full', orientation === 'horizontal' ? '-right-12 top-1/2 -translate-y-1/2' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', className )} disabled={!canScrollNext} onClick={scrollNext} {...props} > <ArrowRight className="h-4 w-4" /> <span className="sr-only">Next slide</span> </Button> ) }) CarouselNext.displayName = 'CarouselNext' export { Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } ================================================ FILE: components/ui/checkbox.tsx ================================================ 'use client' import * as React from 'react' import * as CheckboxPrimitive from '@radix-ui/react-checkbox' import { Check } from 'lucide-react' import { cn } from '@/lib/utils' const Checkbox = React.forwardRef< React.ElementRef<typeof CheckboxPrimitive.Root>, React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> >(({ className, ...props }, ref) => ( <CheckboxPrimitive.Root ref={ref} className={cn( 'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', className )} {...props} > <CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')} > <Check className="h-4 w-4" /> </CheckboxPrimitive.Indicator> </CheckboxPrimitive.Root> )) Checkbox.displayName = CheckboxPrimitive.Root.displayName export { Checkbox } ================================================ FILE: components/ui/collapsible.tsx ================================================ 'use client' import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' const Collapsible = CollapsiblePrimitive.Root const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent export { Collapsible, CollapsibleContent, CollapsibleTrigger } ================================================ FILE: components/ui/command.tsx ================================================ 'use client' import * as React from 'react' import { type DialogProps } from '@radix-ui/react-dialog' import { Command as CommandPrimitive } from 'cmdk' import { Search } from 'lucide-react' import { cn } from '@/lib/utils' import { Dialog, DialogContent } from '@/components/ui/dialog' const Command = React.forwardRef< React.ElementRef<typeof CommandPrimitive>, React.ComponentPropsWithoutRef<typeof CommandPrimitive> >(({ className, ...props }, ref) => ( <CommandPrimitive ref={ref} className={cn( 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', className )} {...props} /> )) Command.displayName = CommandPrimitive.displayName const CommandDialog = ({ children, ...props }: DialogProps) => { return ( <Dialog {...props}> <DialogContent className="overflow-hidden p-0 shadow-lg"> <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> {children} </Command> </DialogContent> </Dialog> ) } const CommandInput = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Input>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> >(({ className, ...props }, ref) => ( <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <CommandPrimitive.Input ref={ref} className={cn( 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', className )} {...props} /> </div> )) CommandInput.displayName = CommandPrimitive.Input.displayName const CommandList = React.forwardRef< React.ElementRef<typeof CommandPrimitive.List>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> >(({ className, ...props }, ref) => ( <CommandPrimitive.List ref={ref} className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)} {...props} /> )) CommandList.displayName = CommandPrimitive.List.displayName const CommandEmpty = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Empty>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> >((props, ref) => ( <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} /> )) CommandEmpty.displayName = CommandPrimitive.Empty.displayName const CommandGroup = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Group>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> >(({ className, ...props }, ref) => ( <CommandPrimitive.Group ref={ref} className={cn( 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', className )} {...props} /> )) CommandGroup.displayName = CommandPrimitive.Group.displayName const CommandSeparator = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> >(({ className, ...props }, ref) => ( <CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} /> )) CommandSeparator.displayName = CommandPrimitive.Separator.displayName const CommandItem = React.forwardRef< React.ElementRef<typeof CommandPrimitive.Item>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> >(({ className, ...props }, ref) => ( <CommandPrimitive.Item ref={ref} className={cn( "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className )} {...props} /> )) CommandItem.displayName = CommandPrimitive.Item.displayName const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <span className={cn( 'ml-auto text-xs tracking-widest text-muted-foreground', className )} {...props} /> ) } CommandShortcut.displayName = 'CommandShortcut' export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } ================================================ FILE: components/ui/dialog.tsx ================================================ 'use client' import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import { X } from 'lucide-react' import { cn } from '@/lib/utils' const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DialogPrimitive.Overlay ref={ref} className={cn( 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} {...props} /> )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content ref={ref} className={cn( 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', className )} {...props} > {children} <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> </DialogPrimitive.Content> </DialogPortal> )) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col space-y-1.5 text-center sm:text-left', className )} {...props} /> ) DialogHeader.displayName = 'DialogHeader' const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className )} {...props} /> ) DialogFooter.displayName = 'DialogFooter' const DialogTitle = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( <DialogPrimitive.Title ref={ref} className={cn( 'text-lg font-semibold leading-none tracking-tight', className )} {...props} /> )) DialogTitle.displayName = DialogPrimitive.Title.displayName const DialogDescription = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )) DialogDescription.displayName = DialogPrimitive.Description.displayName export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } ================================================ FILE: components/ui/drawer.tsx ================================================ 'use client' import * as React from 'react' import { Drawer as DrawerPrimitive } from 'vaul' import { cn } from '@/lib/utils/index' const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} /> ) Drawer.displayName = 'Drawer' const DrawerTrigger = DrawerPrimitive.Trigger const DrawerPortal = DrawerPrimitive.Portal const DrawerClose = DrawerPrimitive.Close const DrawerOverlay = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Overlay ref={ref} className={cn('fixed inset-0 z-50 bg-black/80', className)} {...props} /> )) DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName const DrawerContent = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> >(({ className, children, ...props }, ref) => ( <DrawerPortal> <DrawerOverlay /> <DrawerPrimitive.Content ref={ref} className={cn( 'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background', className )} {...props} > <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> {children} </DrawerPrimitive.Content> </DrawerPortal> )) DrawerContent.displayName = 'DrawerContent' const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)} {...props} /> ) DrawerHeader.displayName = 'DrawerHeader' const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} /> ) DrawerFooter.displayName = 'DrawerFooter' const DrawerTitle = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Title ref={ref} className={cn( 'text-lg font-semibold leading-none tracking-tight', className )} {...props} /> )) DrawerTitle.displayName = DrawerPrimitive.Title.displayName const DrawerDescription = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )) DrawerDescription.displayName = DrawerPrimitive.Description.displayName export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger } ================================================ FILE: components/ui/dropdown-menu.tsx ================================================ 'use client' import * as React from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import { cn } from '@/lib/utils/index' function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> } function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { return ( <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> ) } function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { return ( <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> ) } function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', className )} {...props} /> </DropdownMenuPrimitive.Portal> ) } function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { return ( <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> ) } function DropdownMenuItem({ className, inset, variant = 'default', ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean variant?: 'default' | 'destructive' }) { return ( <DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn( "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} /> ) } function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { return ( <DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} checked={checked} {...props} > <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CheckIcon className="size-4" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.CheckboxItem> ) } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { return ( <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> ) } function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { return ( <DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} > <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CircleIcon className="size-2 fill-current" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.RadioItem> ) } function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) { return ( <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn( 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className )} {...props} /> ) } function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { return ( <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn('bg-border -mx-1 my-1 h-px', className)} {...props} /> ) } function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="dropdown-menu-shortcut" className={cn( 'text-muted-foreground ml-auto text-xs tracking-widest', className )} {...props} /> ) } function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> } function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) { return ( <DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', className )} {...props} > {children} <ChevronRightIcon className="ml-auto size-4" /> </DropdownMenuPrimitive.SubTrigger> ) } function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', className )} {...props} /> </DropdownMenuPrimitive.Portal> ) } export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } ================================================ FILE: components/ui/hover-card.tsx ================================================ 'use client' import * as React from 'react' import * as HoverCardPrimitive from '@radix-ui/react-hover-card' import { cn } from '@/lib/utils' const HoverCard = HoverCardPrimitive.Root const HoverCardTrigger = HoverCardPrimitive.Trigger const HoverCardContent = React.forwardRef< React.ElementRef<typeof HoverCardPrimitive.Content>, React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( <HoverCardPrimitive.Portal> <HoverCardPrimitive.Content ref={ref} align={align} sideOffset={sideOffset} className={cn( 'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none', 'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2', 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'data-[state=open]:duration-150 data-[state=closed]:duration-100', className )} {...props} > {props.children} <HoverCardPrimitive.Arrow className="fill-popover" /> </HoverCardPrimitive.Content> </HoverCardPrimitive.Portal> )) HoverCardContent.displayName = HoverCardPrimitive.Content.displayName export { HoverCard, HoverCardContent, HoverCardTrigger } ================================================ FILE: components/ui/icons.tsx ================================================ 'use client' import { useEffect, useRef } from 'react' import { cn } from '@/lib/utils' function IconLogo({ className, ...props }: React.ComponentProps<'svg'>) { return ( <svg fill="currentColor" viewBox="0 0 256 256" role="img" xmlns="http://www.w3.org/2000/svg" className={cn('h-4 w-4', className)} {...props} > <circle cx="128" cy="128" r="128" fill="black"></circle> <circle cx="102" cy="128" r="18" fill="white"></circle> <circle cx="154" cy="128" r="18" fill="white"></circle> </svg> ) } function IconLogoOutline({ className, ...props }: React.ComponentProps<'svg'>) { return ( <svg fill="currentColor" viewBox="0 0 256 256" role="img" xmlns="http://www.w3.org/2000/svg" className={cn('h-4 w-4', className)} {...props} > <circle cx="128" cy="128" r="108" fill="none" stroke="currentColor" strokeWidth="24" ></circle> <circle cx="102" cy="128" r="18" fill="currentColor"></circle> <circle cx="154" cy="128" r="18" fill="currentColor"></circle> </svg> ) } function IconBlinkingLogo({ className, ...props }: React.ComponentProps<'svg'>) { const svgRef = useRef<SVGSVGElement>(null) useEffect(() => { const blinkElements = document.querySelectorAll('.blink') const initialPositions = Array.from(blinkElements).map(el => ({ cx: parseFloat(el.getAttribute('cx') || '0'), cy: parseFloat(el.getAttribute('cy') || '0') })) const triggerBlink = () => { blinkElements.forEach(el => { el.classList.add('animate-blink') setTimeout(() => { el.classList.remove('animate-blink') }, 200) }) } const randomInterval = () => Math.random() * 8000 + 2000 let timeoutId: ReturnType<typeof setTimeout> const startBlinking = () => { triggerBlink() timeoutId = setTimeout(startBlinking, randomInterval()) } startBlinking() const handleMove = (clientX: number, clientY: number) => { if (svgRef.current) { const rect = svgRef.current.getBoundingClientRect() const mouseX = clientX - rect.left - rect.width / 2 - 256 const mouseY = clientY - rect.top - rect.height / 2 const maxMove = 60 blinkElements.forEach((el, index) => { const { cx, cy } = initialPositions[index] const targetDx = Math.min((mouseX - cx) * 0.1, maxMove) const targetDy = Math.min((mouseY - cy) * 0.1, maxMove) let velocityX = 0 let velocityY = 0 const damping = 0.05 const animate = () => { const currentCx = parseFloat(el.getAttribute('cx') || '0') const currentCy = parseFloat(el.getAttribute('cy') || '0') const dx = (targetDx - (currentCx - cx)) * 0.1 const dy = (targetDy - (currentCy - cy)) * 0.1 velocityX = velocityX * damping + dx velocityY = velocityY * damping + dy el.setAttribute('cx', (currentCx + velocityX).toString()) el.setAttribute('cy', (currentCy + velocityY).toString()) if (Math.abs(velocityX) > 0.1 || Math.abs(velocityY) > 0.1) { requestAnimationFrame(animate) } } requestAnimationFrame(animate) }) } } const handleMouseMove = (event: MouseEvent) => { handleMove(event.clientX, event.clientY) } const handleTouchMove = (event: TouchEvent) => { if (event.touches.length > 0) { handleMove(event.touches[0].clientX, event.touches[0].clientY) } } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('touchmove', handleTouchMove) return () => { clearTimeout(timeoutId) window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('touchmove', handleTouchMove) } }, []) return ( <svg ref={svgRef} fill="currentColor" viewBox="0 0 256 256" role="img" xmlns="http://www.w3.org/2000/svg" className={cn('h-4 w-4', className)} {...props} > <circle cx="128" cy="128" r="128" fill="#222"></circle> <ellipse cx="102" cy="128" rx="18" ry="18" fill="white" className="blink" ></ellipse> <ellipse cx="154" cy="128" rx="18" ry="18" fill="white" className="blink" ></ellipse> </svg> ) } export { IconBlinkingLogo, IconLogo, IconLogoOutline } ================================================ FILE: components/ui/index.ts ================================================ export * from './button' export * from './tooltip' export * from './tooltip-button' ================================================ FILE: components/ui/input.tsx ================================================ import * as React from 'react' import { cn } from '@/lib/utils/index' export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} const Input = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type, ...props }, ref) => { return ( <input type={type} className={cn( 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', className )} ref={ref} {...props} /> ) } ) Input.displayName = 'Input' export { Input } ================================================ FILE: components/ui/label.tsx ================================================ 'use client' import * as React from 'react' import * as LabelPrimitive from '@radix-ui/react-label' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils/index' const labelVariants = cva( 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' ) const Label = React.forwardRef< React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants> >(({ className, ...props }, ref) => ( <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> )) Label.displayName = LabelPrimitive.Root.displayName export { Label } ================================================ FILE: components/ui/password-input.tsx ================================================ import * as React from 'react' import { useState } from 'react' import { Eye, EyeOff } from 'lucide-react' import { Input, InputProps } from '@/components/ui/input' import { Button } from './button' const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type = 'password', ...props }, ref) => { const [showPassword, setShowPassword] = useState(false) return ( <div className="relative flex"> <Input type={showPassword ? 'text' : type} className={className} ref={ref} {...props} /> <Button type="button" variant="ghost" size="icon" className="h-full px-3 py-2 hover:bg-transparent absolute right-0 flex items-center justify-center" onClick={() => setShowPassword(!showPassword)} > {showPassword ? ( <EyeOff className="h-4 w-4" /> ) : ( <Eye className="h-4 w-4" /> )} </Button> </div> ) } ) PasswordInput.displayName = 'PasswordInput' export { PasswordInput } ================================================ FILE: components/ui/popover.tsx ================================================ 'use client' import * as React from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' import { cn } from '@/lib/utils' const Popover = PopoverPrimitive.Root const PopoverTrigger = PopoverPrimitive.Trigger const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( <PopoverPrimitive.Portal> <PopoverPrimitive.Content ref={ref} align={align} sideOffset={sideOffset} className={cn( 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} /> </PopoverPrimitive.Portal> )) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverContent, PopoverTrigger } ================================================ FILE: components/ui/select.tsx ================================================ 'use client' import * as React from 'react' import * as SelectPrimitive from '@radix-ui/react-select' import { Check, ChevronDown, ChevronUp } from 'lucide-react' import { cn } from '@/lib/utils' const Select = SelectPrimitive.Root const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( <SelectPrimitive.Trigger ref={ref} className={cn( 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', className )} {...props} > {children} <SelectPrimitive.Icon asChild> <ChevronDown className="h-4 w-4 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )) SelectTrigger.displayName = SelectPrimitive.Trigger.displayName const SelectScrollUpButton = React.forwardRef< React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> >(({ className, ...props }, ref) => ( <SelectPrimitive.ScrollUpButton ref={ref} className={cn( 'flex cursor-default items-center justify-center py-1', className )} {...props} > <ChevronUp className="h-4 w-4" /> </SelectPrimitive.ScrollUpButton> )) SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName const SelectScrollDownButton = React.forwardRef< React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> >(({ className, ...props }, ref) => ( <SelectPrimitive.ScrollDownButton ref={ref} className={cn( 'flex cursor-default items-center justify-center py-1', className )} {...props} > <ChevronDown className="h-4 w-4" /> </SelectPrimitive.ScrollDownButton> )) SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName const SelectContent = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Content>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> >(({ className, children, position = 'popper', ...props }, ref) => ( <SelectPrimitive.Portal> <SelectPrimitive.Content ref={ref} className={cn( 'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className )} position={position} {...props} > <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn( 'p-1', position === 'popper' && 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)' )} > {children} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> )) SelectContent.displayName = SelectPrimitive.Content.displayName const SelectLabel = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Label>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> >(({ className, ...props }, ref) => ( <SelectPrimitive.Label ref={ref} className={cn('py-1.5 pl-1.5 pr-12 text-sm font-semibold', className)} {...props} /> )) SelectLabel.displayName = SelectPrimitive.Label.displayName const SelectItem = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Item>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> >(({ className, children, ...props }, ref) => ( <SelectPrimitive.Item ref={ref} className={cn( 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-1.5 pr-12 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50', className )} {...props} > <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> <Check className="h-4 w-4" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> )) SelectItem.displayName = SelectPrimitive.Item.displayName const SelectSeparator = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} /> )) SelectSeparator.displayName = SelectPrimitive.Separator.displayName export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } ================================================ FILE: components/ui/separator.tsx ================================================ 'use client' import * as React from 'react' import * as SeparatorPrimitive from '@radix-ui/react-separator' import { cn } from '@/lib/utils/index' const Separator = React.forwardRef< React.ElementRef<typeof SeparatorPrimitive.Root>, React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> >( ( { className, orientation = 'horizontal', decorative = true, ...props }, ref ) => ( <SeparatorPrimitive.Root ref={ref} decorative={decorative} orientation={orientation} className={cn( 'shrink-0 bg-border', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className )} {...props} /> ) ) Separator.displayName = SeparatorPrimitive.Root.displayName export { Separator } ================================================ FILE: components/ui/sheet.tsx ================================================ 'use client' import * as React from 'react' import * as SheetPrimitive from '@radix-ui/react-dialog' import { cva, type VariantProps } from 'class-variance-authority' import { X } from 'lucide-react' import { cn } from '@/lib/utils/index' const Sheet = SheetPrimitive.Root const SheetTrigger = SheetPrimitive.Trigger const SheetClose = SheetPrimitive.Close const SheetPortal = SheetPrimitive.Portal const SheetOverlay = React.forwardRef< React.ElementRef<typeof SheetPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> >(({ className, ...props }, ref) => ( <SheetPrimitive.Overlay className={cn( 'fixed inset-0 z-50 bg-black/30 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} {...props} ref={ref} /> )) SheetOverlay.displayName = SheetPrimitive.Overlay.displayName const sheetVariants = cva( 'fixed z-50 gap-4 bg-background p-4 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', { variants: { side: { top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm' } }, defaultVariants: { side: 'right' } } ) interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {} const SheetContent = React.forwardRef< React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps >(({ side = 'right', className, children, ...props }, ref) => ( <SheetPortal> <SheetOverlay /> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props} > {children} <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </SheetPrimitive.Close> </SheetPrimitive.Content> </SheetPortal> )) SheetContent.displayName = SheetPrimitive.Content.displayName const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col space-y-2 text-center sm:text-left', className )} {...props} /> ) SheetHeader.displayName = 'SheetHeader' const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className )} {...props} /> ) SheetFooter.displayName = 'SheetFooter' const SheetTitle = React.forwardRef< React.ElementRef<typeof SheetPrimitive.Title>, React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> >(({ className, ...props }, ref) => ( <SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} /> )) SheetTitle.displayName = SheetPrimitive.Title.displayName const SheetDescription = React.forwardRef< React.ElementRef<typeof SheetPrimitive.Description>, React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> >(({ className, ...props }, ref) => ( <SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )) SheetDescription.displayName = SheetPrimitive.Description.displayName export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger } ================================================ FILE: components/ui/sidebar.tsx ================================================ 'use client' import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, VariantProps } from 'class-variance-authority' import { PanelLeft } from 'lucide-react' import { cn } from '@/lib/utils/index' import { useIsMobile } from '@/hooks/use-mobile' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' const SIDEBAR_COOKIE_NAME = 'sidebar_state' const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = '16rem' const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_ICON = '3rem' const SIDEBAR_KEYBOARD_SHORTCUT = 'b' type SidebarContextProps = { state: 'expanded' | 'collapsed' open: boolean setOpen: (open: boolean) => void openMobile: boolean setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void } const SidebarContext = React.createContext<SidebarContextProps | null>(null) function useSidebar() { const context = React.useContext(SidebarContext) if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.') } return context } // Helper function to get cookie value function getCookie(name: string): string | null { if (typeof document === 'undefined') return null const value = `; ${document.cookie}` const parts = value.split(`; ${name}=`) if (parts.length === 2) { return parts.pop()?.split(';').shift() || null } return null } const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> & { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void } >( ( { defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref ) => { const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) // Initialize state - for SSR we use defaultOpen, for client we read from cookie const [_open, _setOpen] = React.useState(defaultOpen) const [isHydrated, setIsHydrated] = React.useState(false) // On client side, immediately read from cookie and update state React.useLayoutEffect(() => { const cookieValue = getCookie(SIDEBAR_COOKIE_NAME) const cookieState = cookieValue !== null ? cookieValue === 'true' : defaultOpen _setOpen(cookieState) setIsHydrated(true) }, [defaultOpen]) const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value if (setOpenProp) { setOpenProp(openState) } else { _setOpen(openState) } // This sets the cookie to keep the sidebar state. if (typeof document !== 'undefined') { document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` } }, [setOpenProp, open] ) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) }, [isMobile, setOpen, setOpenMobile]) // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault() toggleSidebar() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [toggleSidebar]) // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed' const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] ) return ( <SidebarContext.Provider value={contextValue}> <TooltipProvider delayDuration={0}> <div style={ { '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style } as React.CSSProperties } className={cn( 'group/sidebar-wrapper flex h-[100dvh] w-full has-data-[variant=inset]:bg-sidebar', // Prevent flash during hydration !isHydrated && 'opacity-0', isHydrated && 'opacity-100 transition-opacity duration-150', className )} ref={ref} {...props} suppressHydrationWarning > {children} </div> </TooltipProvider> </SidebarContext.Provider> ) } ) SidebarProvider.displayName = 'SidebarProvider' const Sidebar = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> & { side?: 'left' | 'right' variant?: 'sidebar' | 'floating' | 'inset' collapsible?: 'offcanvas' | 'icon' | 'none' } >( ( { side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref ) => { const { isMobile, state, openMobile, setOpenMobile } = useSidebar() if (collapsible === 'none') { return ( <div className={cn( 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className )} ref={ref} {...props} > {children} </div> ) } if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent data-sidebar="sidebar" data-mobile="true" className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" style={ { '--sidebar-width': SIDEBAR_WIDTH_MOBILE } as React.CSSProperties } side={side} > <SheetHeader className="sr-only"> <SheetTitle>Sidebar</SheetTitle> <SheetDescription>Displays the mobile sidebar.</SheetDescription> </SheetHeader> <div className="flex h-full w-full flex-col">{children}</div> </SheetContent> </Sheet> ) } return ( <div ref={ref} className="group peer hidden text-sidebar-foreground md:block" data-state={state} data-collapsible={state === 'collapsed' ? collapsible : ''} data-variant={variant} data-side={side} > {/* This is what handles the sidebar gap on desktop */} <div className={cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'group-data-[collapsible=offcanvas]:w-0', 'group-data-[side=right]:rotate-180', variant === 'floating' || variant === 'inset' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)' )} /> <div className={cn( 'fixed inset-y-0 z-10 hidden h-[100dvh] w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', side === 'left' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', // Adjust the padding for floating and inset variants. variant === 'floating' || variant === 'inset' ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', className )} {...props} > <div data-sidebar="sidebar" className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm" > {children} </div> </div> </div> ) } ) Sidebar.displayName = 'Sidebar' const SidebarTrigger = React.forwardRef< React.ElementRef<typeof Button>, React.ComponentProps<typeof Button> >(({ className, onClick, ...props }, ref) => { const { toggleSidebar } = useSidebar() return ( <Button ref={ref} data-sidebar="trigger" variant="ghost" size="icon" className={cn('size-6', className)} onClick={event => { onClick?.(event) toggleSidebar() }} {...props} > <PanelLeft size={18} /> <span className="sr-only">Toggle Sidebar</span> </Button> ) }) SidebarTrigger.displayName = 'SidebarTrigger' const SidebarRail = React.forwardRef< HTMLButtonElement, React.ComponentProps<'button'> >(({ className, ...props }, ref) => { const { toggleSidebar } = useSidebar() return ( <button ref={ref} data-sidebar="rail" aria-label="Toggle Sidebar" tabIndex={-1} onClick={toggleSidebar} title="Toggle Sidebar" className={cn( 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', className )} {...props} /> ) }) SidebarRail.displayName = 'SidebarRail' const SidebarInset = React.forwardRef< HTMLDivElement, React.ComponentProps<'main'> >(({ className, ...props }, ref) => { return ( <main ref={ref} className={cn( 'relative flex w-full flex-1 flex-col bg-background', 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm', className )} {...props} /> ) }) SidebarInset.displayName = 'SidebarInset' const SidebarInput = React.forwardRef< React.ElementRef<typeof Input>, React.ComponentProps<typeof Input> >(({ className, ...props }, ref) => { return ( <Input ref={ref} data-sidebar="input" className={cn( 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring', className )} {...props} /> ) }) SidebarInput.displayName = 'SidebarInput' const SidebarHeader = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => { return ( <div ref={ref} data-sidebar="header" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ) }) SidebarHeader.displayName = 'SidebarHeader' const SidebarFooter = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => { return ( <div ref={ref} data-sidebar="footer" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ) }) SidebarFooter.displayName = 'SidebarFooter' const SidebarSeparator = React.forwardRef< React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator> >(({ className, ...props }, ref) => { return ( <Separator ref={ref} data-sidebar="separator" className={cn('mx-2 w-auto bg-sidebar-border', className)} {...props} /> ) }) SidebarSeparator.displayName = 'SidebarSeparator' const SidebarContent = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => { return ( <div ref={ref} data-sidebar="content" className={cn( 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', className )} {...props} /> ) }) SidebarContent.displayName = 'SidebarContent' const SidebarGroup = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => { return ( <div ref={ref} data-sidebar="group" className={cn('relative flex w-full min-w-0 flex-col p-2', className)} {...props} /> ) }) SidebarGroup.displayName = 'SidebarGroup' const SidebarGroupLabel = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'div' return ( <Comp ref={ref} data-sidebar="group-label" className={cn( 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', className )} {...props} /> ) }) SidebarGroupLabel.displayName = 'SidebarGroupLabel' const SidebarGroupAction = React.forwardRef< HTMLButtonElement, React.ComponentProps<'button'> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( <Comp ref={ref} data-sidebar="group-action" className={cn( 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ) }) SidebarGroupAction.displayName = 'SidebarGroupAction' const SidebarGroupContent = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => ( <div ref={ref} data-sidebar="group-content" className={cn('w-full text-sm', className)} {...props} /> )) SidebarGroupContent.displayName = 'SidebarGroupContent' const SidebarMenu = React.forwardRef< HTMLUListElement, React.ComponentProps<'ul'> >(({ className, ...props }, ref) => ( <ul ref={ref} data-sidebar="menu" className={cn('flex w-full min-w-0 flex-col gap-1', className)} {...props} /> )) SidebarMenu.displayName = 'SidebarMenu' const SidebarMenuItem = React.forwardRef< HTMLLIElement, React.ComponentProps<'li'> >(({ className, ...props }, ref) => ( <li ref={ref} data-sidebar="menu-item" className={cn('group/menu-item relative', className)} {...props} /> )) SidebarMenuItem.displayName = 'SidebarMenuItem' const sidebarMenuButtonVariants = cva( 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]' }, size: { default: 'h-8 text-sm', sm: 'h-7 text-xs', lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!' } }, defaultVariants: { variant: 'default', size: 'default' } } ) const SidebarMenuButton = React.forwardRef< HTMLButtonElement, React.ComponentProps<'button'> & { asChild?: boolean isActive?: boolean tooltip?: string | React.ComponentProps<typeof TooltipContent> } & VariantProps<typeof sidebarMenuButtonVariants> >( ( { asChild = false, isActive = false, variant = 'default', size = 'default', tooltip, className, ...props }, ref ) => { const Comp = asChild ? Slot : 'button' const { isMobile, state } = useSidebar() const button = ( <Comp ref={ref} data-sidebar="menu-button" data-size={size} data-active={isActive} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} {...props} /> ) if (!tooltip) { return button } if (typeof tooltip === 'string') { tooltip = { children: tooltip } } return ( <Tooltip> <TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} /> </Tooltip> ) } ) SidebarMenuButton.displayName = 'SidebarMenuButton' const SidebarMenuAction = React.forwardRef< HTMLButtonElement, React.ComponentProps<'button'> & { asChild?: boolean showOnHover?: boolean } >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( <Comp ref={ref} data-sidebar="menu-action" className={cn( 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', className )} {...props} /> ) }) SidebarMenuAction.displayName = 'SidebarMenuAction' const SidebarMenuBadge = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> >(({ className, ...props }, ref) => ( <div ref={ref} data-sidebar="menu-badge" className={cn( 'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> )) SidebarMenuBadge.displayName = 'SidebarMenuBadge' const SidebarMenuSkeleton = React.forwardRef< HTMLDivElement, React.ComponentProps<'div'> & { showIcon?: boolean } >(({ className, showIcon = false, ...props }, ref) => { // Random width between 50 to 90%. const width = '75%' // Use a fixed width return ( <div ref={ref} data-sidebar="menu-skeleton" className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} {...props} > {showIcon && ( <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" /> )} <Skeleton className="h-4 max-w-(--skeleton-width) flex-1" data-sidebar="menu-skeleton-text" style={ { '--skeleton-width': width } as React.CSSProperties } /> </div> ) }) SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton' const SidebarMenuSub = React.forwardRef< HTMLUListElement, React.ComponentProps<'ul'> >(({ className, ...props }, ref) => ( <ul ref={ref} data-sidebar="menu-sub" className={cn( 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> )) SidebarMenuSub.displayName = 'SidebarMenuSub' const SidebarMenuSubItem = React.forwardRef< HTMLLIElement, React.ComponentProps<'li'> >(({ ...props }, ref) => <li ref={ref} {...props} />) SidebarMenuSubItem.displayName = 'SidebarMenuSubItem' const SidebarMenuSubButton = React.forwardRef< HTMLAnchorElement, React.ComponentProps<'a'> & { asChild?: boolean size?: 'sm' | 'md' isActive?: boolean } >(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => { const Comp = asChild ? Slot : 'a' return ( <Comp ref={ref} data-sidebar="menu-sub-button" data-size={size} data-active={isActive} className={cn( 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs', size === 'md' && 'text-sm', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ) }) SidebarMenuSubButton.displayName = 'SidebarMenuSubButton' export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, useSidebar } ================================================ FILE: components/ui/skeleton.tsx ================================================ import { cn } from '@/lib/utils/index' function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return ( <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} /> ) } export { Skeleton } ================================================ FILE: components/ui/slider.tsx ================================================ 'use client' import * as React from 'react' import * as SliderPrimitive from '@radix-ui/react-slider' import { cn } from '@/lib/utils' const Slider = React.forwardRef< React.ElementRef<typeof SliderPrimitive.Root>, React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> >(({ className, ...props }, ref) => ( <SliderPrimitive.Root ref={ref} className={cn( 'relative flex w-full touch-none select-none items-center', className )} {...props} > <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Range className="absolute h-full bg-primary" /> </SliderPrimitive.Track> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> </SliderPrimitive.Root> )) Slider.displayName = SliderPrimitive.Root.displayName export { Slider } ================================================ FILE: components/ui/sonner.tsx ================================================ 'use client' import { useTheme } from 'next-themes' import { Toaster as Sonner } from 'sonner' type ToasterProps = React.ComponentProps<typeof Sonner> const Toaster = ({ ...props }: ToasterProps) => { const { theme = 'system' } = useTheme() return ( <Sonner theme={theme as ToasterProps['theme']} className="toaster group" toastOptions={{ classNames: { toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', description: 'group-[.toast]:text-muted-foreground', actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground' } }} {...props} /> ) } export { Toaster } ================================================ FILE: components/ui/spinner.tsx ================================================ // Based on: https://github.com/vercel/ai/blob/main/examples/next-ai-rsc/components/llm-stocks/spinner.tsx import { cn } from '@/lib/utils' import { IconLogo } from './icons' interface SpinnerProps extends React.SVGProps<SVGSVGElement> {} export const Spinner = ({ className, ...props }: SpinnerProps) => ( <svg fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" xmlns="http://www.w3.org/2000/svg" className={cn('h-5 w-5 animate-spin stroke-zinc-400', className)} {...props} > <path d="M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12"></path> </svg> ) export const LogoSpinner = () => ( <div className="p-4 border border-background"> <IconLogo className="w-4 h-4 animate-spin" /> </div> ) ================================================ FILE: components/ui/status-indicator.tsx ================================================ import { ReactNode } from 'react' import { LucideIcon } from 'lucide-react' interface StatusIndicatorProps { icon: LucideIcon iconClassName?: string children?: ReactNode } export function StatusIndicator({ icon: Icon, iconClassName, children }: StatusIndicatorProps) { return ( <span className="flex items-center gap-1 text-muted-foreground text-xs"> <Icon size={16} className={iconClassName} /> {children && <span>{children}</span>} </span> ) } ================================================ FILE: components/ui/switch.tsx ================================================ 'use client' import * as React from 'react' import * as SwitchPrimitives from '@radix-ui/react-switch' import { cn } from '@/lib/utils' const Switch = React.forwardRef< React.ElementRef<typeof SwitchPrimitives.Root>, React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> >(({ className, ...props }, ref) => ( <SwitchPrimitives.Root className={cn( 'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input', className )} {...props} ref={ref} > <SwitchPrimitives.Thumb className={cn( 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0' )} /> </SwitchPrimitives.Root> )) Switch.displayName = SwitchPrimitives.Root.displayName export { Switch } ================================================ FILE: components/ui/textarea.tsx ================================================ import * as React from 'react' import { cn } from '@/lib/utils' export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( ({ className, ...props }, ref) => { return ( <textarea className={cn( 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', className )} ref={ref} {...props} /> ) } ) Textarea.displayName = 'Textarea' export { Textarea } ================================================ FILE: components/ui/toggle.tsx ================================================ 'use client' import * as React from 'react' import * as TogglePrimitive from '@radix-ui/react-toggle' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const toggleVariants = cva( 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2', { variants: { variant: { default: 'bg-transparent data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', outline: 'border border-input hover:bg-accent hover:text-accent-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground' }, size: { default: 'h-10 px-3 min-w-10', sm: 'h-9 px-2.5 min-w-9', lg: 'h-11 px-5 min-w-11' } }, defaultVariants: { variant: 'default', size: 'default' } } ) const Toggle = React.forwardRef< React.ElementRef<typeof TogglePrimitive.Root>, React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants> >(({ className, variant, size, ...props }, ref) => ( <TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} /> )) Toggle.displayName = TogglePrimitive.Root.displayName export { Toggle, toggleVariants } ================================================ FILE: components/ui/tooltip-button.tsx ================================================ 'use client' import * as React from 'react' import { Button, ButtonProps } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' interface TooltipButtonProps extends ButtonProps { /** * The tooltip content to display. * Can be a string or TooltipContent props. */ tooltipContent: | string | (Omit< React.ComponentPropsWithoutRef<typeof TooltipContent>, 'children' > & { children: React.ReactNode }) /** * The content of the button. */ children: React.ReactNode } /** * A button component with a tooltip. */ export const TooltipButton = React.forwardRef< HTMLButtonElement, TooltipButtonProps >(({ tooltipContent, children, ...buttonProps }, ref) => { const tooltipProps = typeof tooltipContent === 'string' ? { children: <p>{tooltipContent}</p> } : tooltipContent return ( <Tooltip> <TooltipTrigger asChild> <Button ref={ref} {...buttonProps}> {children} </Button> </TooltipTrigger> <TooltipContent {...tooltipProps} /> </Tooltip> ) }) TooltipButton.displayName = 'TooltipButton' ================================================ FILE: components/ui/tooltip.tsx ================================================ 'use client' import * as React from 'react' import * as TooltipPrimitive from '@radix-ui/react-tooltip' import { cn } from '@/lib/utils/index' const TooltipProvider = TooltipPrimitive.Provider const Tooltip = TooltipPrimitive.Root const TooltipTrigger = TooltipPrimitive.Trigger const TooltipContent = React.forwardRef< React.ElementRef<typeof TooltipPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( <TooltipPrimitive.Content ref={ref} sideOffset={sideOffset} className={cn( 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} /> )) TooltipContent.displayName = TooltipPrimitive.Content.displayName export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } ================================================ FILE: components/update-password-form.tsx ================================================ 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils/index' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' export function UpdatePasswordForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { const [password, setPassword] = useState('') const [error, setError] = useState<string | null>(null) const [isLoading, setIsLoading] = useState(false) const router = useRouter() const handleForgotPassword = async (e: React.FormEvent) => { e.preventDefault() const supabase = createClient() setIsLoading(true) setError(null) try { const { error } = await supabase.auth.updateUser({ password }) if (error) throw error // Redirect to root and refresh to ensure server components get updated session. router.push('/') router.refresh() } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { setIsLoading(false) } } return ( <div className={cn('flex flex-col gap-6', className)} {...props}> <Card> <CardHeader> <CardTitle className="text-2xl">Reset Your Password</CardTitle> <CardDescription> Please enter your new password below. </CardDescription> </CardHeader> <CardContent> <form onSubmit={handleForgotPassword}> <div className="flex flex-col gap-6"> <div className="grid gap-2"> <Label htmlFor="password">New password</Label> <Input id="password" type="password" placeholder="New password" required value={password} onChange={e => setPassword(e.target.value)} /> </div> {error && <p className="text-sm text-red-500">{error}</p>} <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? 'Saving...' : 'Save new password'} </Button> </div> </form> </CardContent> </Card> </div> ) } ================================================ FILE: components/uploaded-file-list.tsx ================================================ 'use client' import React from 'react' import Image from 'next/image' import { Loader2, X } from 'lucide-react' import { UploadedFile } from '@/lib/types' interface UploadedFileListProps { files: UploadedFile[] onRemove: (index: number) => void } export const UploadedFileList = React.memo(function UploadedFileList({ files, onRemove }: UploadedFileListProps) { return ( <div className="w-full flex p-4 max-w-3xl mx-auto"> <div className="flex gap-6 overflow-x-auto"> {files.map((it, index) => ( <div key={index} className="relative w-20 shrink-0 flex flex-col items-center" > <div className="relative w-20 aspect-7/5 rounded-lg overflow-hidden shadow-sm border bg-muted/20 dark:bg-muted/10"> {it.file.type.startsWith('image/') ? ( <Image src={URL.createObjectURL(it.file)} alt={`file-${index}`} fill className="object-cover" sizes="112px" /> ) : ( <div className="w-full h-full flex items-center justify-center text-sm font-semibold bg-gray-300 dark:bg-gray-700"> {it.file.name.split('.').pop()?.toUpperCase()} </div> )} {/* Spinner overlay while uploading */} {it.status === 'uploading' && ( <div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10"> <Loader2 className="animate-spin text-white" size={20} /> </div> )} <button type="button" onClick={() => onRemove(index)} className="absolute top-1 right-1 bg-black/40 hover:bg-red-600 text-white rounded-full p-1 z-20" > <X size={12} /> </button> </div> <div className="mt-2 text-xs text-center text-foreground truncate w-full"> {it.name || it.file.name} </div> </div> ))} </div> </div> ) }) ================================================ FILE: components/user-file-section.tsx ================================================ import React from 'react' import { AttachmentPreview } from './attachment-preview' interface UserFileSectionProps { file: { name: string url: string contentType: string } } export const UserFileSection: React.FC<UserFileSectionProps> = ({ file }) => { return <AttachmentPreview attachments={[file]} /> } ================================================ FILE: components/user-menu.tsx ================================================ 'use client' import { useRouter } from 'next/navigation' import { User } from '@supabase/supabase-js' import { Link2, LogOut, Palette } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Button } from './ui/button' import { ExternalLinkItems } from './external-link-items' import { ThemeMenuItems } from './theme-menu-items' interface UserMenuProps { user: User } export default function UserMenu({ user }: UserMenuProps) { const router = useRouter() const userName = user.user_metadata?.full_name || user.user_metadata?.name || 'User' const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture const getInitials = (name: string, email: string | undefined) => { if (name && name !== 'User') { const names = name.split(' ') if (names.length > 1) { return `${names[0][0]}${names[names.length - 1][0]}`.toUpperCase() } return name.substring(0, 2).toUpperCase() } if (email) { return email.split('@')[0].substring(0, 2).toUpperCase() } return 'U' } const handleLogout = async () => { const supabase = createClient() await supabase.auth.signOut() router.push('/') router.refresh() } return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="relative h-6 w-6 rounded-full"> <Avatar className="h-6 w-6"> <AvatarImage src={avatarUrl} alt={userName} /> <AvatarFallback>{getInitials(userName, user.email)}</AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-60" align="end" forceMount> <DropdownMenuLabel className="font-normal"> <div className="flex flex-col space-y-1"> <p className="text-sm font-medium leading-none truncate"> {userName} </p> <p className="text-xs leading-none text-muted-foreground truncate"> {user.email} </p> </div> </DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger> <Palette className="mr-2 h-4 w-4" /> <span>Theme</span> </DropdownMenuSubTrigger> <DropdownMenuSubContent> <ThemeMenuItems /> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSub> <DropdownMenuSubTrigger> <Link2 className="mr-2 h-4 w-4" /> <span>Links</span> </DropdownMenuSubTrigger> <DropdownMenuSubContent> <ExternalLinkItems /> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSeparator /> <DropdownMenuItem onClick={handleLogout}> <LogOut className="mr-2 h-4 w-4" /> <span>Logout</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) } ================================================ FILE: components/user-text-section.tsx ================================================ 'use client' import React, { useEffect, useRef, useState } from 'react' import TextareaAutosize from 'react-textarea-autosize' import { Pencil } from 'lucide-react' import { cn } from '@/lib/utils' import { Button } from './ui/button' import { CollapsibleMessage } from './collapsible-message' interface UserTextSectionProps { content: string messageId?: string onUpdateMessage?: (messageId: string, newContent: string) => Promise<void> } export const UserTextSection: React.FC<UserTextSectionProps> = ({ content, messageId, onUpdateMessage }) => { const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState(content) const [isComposing, setIsComposing] = useState(false) const [enterDisabled, setEnterDisabled] = useState(false) const enterResetTimeoutRef = useRef<NodeJS.Timeout | null>(null) useEffect(() => { return () => { if (enterResetTimeoutRef.current) { clearTimeout(enterResetTimeoutRef.current) } } }, []) const handleEditClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation() setEditedContent(content) setIsEditing(true) } const handleCancelClick = () => { setIsEditing(false) } const handleSaveClick = async () => { if (!onUpdateMessage || !messageId) return setIsEditing(false) try { await onUpdateMessage(messageId, editedContent) } catch (error) { console.error('Failed to save message:', error) } } const handleTextareaKeyDown = ( event: React.KeyboardEvent<HTMLTextAreaElement> ) => { if (event.key !== 'Enter') { return } if (event.shiftKey || isComposing || enterDisabled) { return } event.preventDefault() void handleSaveClick() } const handleCompositionStart = () => { setIsComposing(true) } const handleCompositionEnd = () => { setIsComposing(false) setEnterDisabled(true) if (enterResetTimeoutRef.current) { clearTimeout(enterResetTimeoutRef.current) } enterResetTimeoutRef.current = setTimeout(() => { setEnterDisabled(false) enterResetTimeoutRef.current = null }, 300) } return ( <CollapsibleMessage role="user"> <div className="flex-1 break-words w-full group outline-hidden relative" tabIndex={0} > {isEditing ? ( <div className="flex flex-col gap-2"> <TextareaAutosize value={editedContent} onChange={e => setEditedContent(e.target.value)} autoFocus onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onKeyDown={handleTextareaKeyDown} className="resize-none flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50" minRows={2} maxRows={10} /> <div className="flex justify-end gap-2"> <Button variant="secondary" size="sm" onClick={handleCancelClick}> Cancel </Button> <Button size="sm" onClick={handleSaveClick}> Save </Button> </div> </div> ) : ( <div className="relative"> <div className="pr-10">{content}</div> <div className={cn( 'absolute top-0 right-0 transition-opacity', 'opacity-0', 'group-focus-within:opacity-100', 'md:opacity-0', 'md:group-hover:opacity-100' )} > <Button variant="ghost" size="icon" className="rounded-full h-7 w-7" onClick={handleEditClick} > <Pencil className="size-3.5" /> </Button> </div> </div> )} </div> </CollapsibleMessage> ) } ================================================ FILE: components/video-carousel-dialog.tsx ================================================ 'use client' import { useEffect, useRef, useState } from 'react' import { SerperSearchResultItem } from '@/lib/types' import { Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' interface VideoCarouselDialogProps { children: React.ReactNode videos: SerperSearchResultItem[] query: string initialIndex?: number // Add initialIndex prop } export function VideoCarouselDialog({ children, videos, query, initialIndex = 0 // Default to 0 }: VideoCarouselDialogProps) { const [api, setApi] = useState<CarouselApi>() const [current, setCurrent] = useState(initialIndex + 1) // Initialize with initialIndex const [count, setCount] = useState(0) const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]) // Update the current and count state when the carousel api is available useEffect(() => { if (api) { setCount(api.scrollSnapList().length) // Initialize current based on initialIndex setCurrent(api.selectedScrollSnap() + 1) api.on('select', () => { const newCurrent = api.selectedScrollSnap() + 1 if (current !== undefined && videoRefs.current[current - 1]) { const prevVideo = videoRefs.current[current - 1] prevVideo?.contentWindow?.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' ) } setCurrent(newCurrent) }) } }, [api, current]) // Keep dependency on current to stop previous video // Scroll to the initial index when the dialog opens and API is ready useEffect(() => { if (api) { api.scrollTo(initialIndex, false) // Scroll instantly } }, [api, initialIndex]) return ( <Dialog> <DialogTrigger asChild>{children}</DialogTrigger> <DialogContent className="sm:max-w-3xl max-h-[80vh] overflow-auto"> <DialogHeader> <DialogTitle>Search Videos</DialogTitle> <DialogDescription className="text-sm">{query}</DialogDescription> </DialogHeader> <div className="py-4"> <Carousel setApi={setApi} className="w-full bg-muted max-h-[60vh]" opts={{ startIndex: initialIndex // Set initial slide }} > <CarouselContent> {videos.map((video, idx) => { const videoId = video.link.split('v=')[1] return ( <CarouselItem key={idx}> <div className="p-1 flex items-center justify-center h-full"> <iframe ref={el => { videoRefs.current[idx] = el }} src={`https://www.youtube.com/embed/${videoId}?enablejsapi=1`} className="w-full aspect-video" title={video.title} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen /> </div> </CarouselItem> ) })} </CarouselContent> <div className="absolute inset-8 flex items-center justify-between p-4 pointer-events-none"> <CarouselPrevious className="w-10 h-10 rounded-full shadow-sm focus:outline-hidden pointer-events-auto"> <span className="sr-only">Previous</span> </CarouselPrevious> <CarouselNext className="w-10 h-10 rounded-full shadow-sm focus:outline-hidden pointer-events-auto"> <span className="sr-only">Next</span> </CarouselNext> </div> </Carousel> <div className="py-2"> <div className="text-center text-sm text-muted-foreground"> {current} of {count} </div> </div> </div> </DialogContent> </Dialog> ) } ================================================ FILE: components/video-result-grid.tsx ================================================ 'use client' import Image from 'next/image' import { PlusCircle } from 'lucide-react' import { SerperSearchResultItem } from '@/lib/types' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Card, CardContent } from '@/components/ui/card' import { VideoCarouselDialog } from './video-carousel-dialog' interface VideoResultGridProps { videos: SerperSearchResultItem[] query: string displayMode: 'chat' | 'artifact' } export function VideoResultGrid({ videos, query, displayMode }: VideoResultGridProps) { const containerClasses = displayMode === 'chat' ? 'flex flex-wrap' : 'grid grid-cols-1 sm:grid-cols-2 gap-4' const itemsToMap = displayMode === 'chat' ? videos.slice(0, 4) : videos return ( <div className={containerClasses}> {itemsToMap.map((video, index) => { const baseUrl = video.imageUrl ? video.imageUrl.split('?')[0] : '' const showOverlay = displayMode === 'chat' && index === 3 && videos.length > 4 const cardClasses = displayMode === 'chat' ? 'w-1/2 md:w-1/4 p-1' : '' return ( <VideoCarouselDialog key={video.link || index} videos={videos} // Pass all filtered videos for the dialog query={query} initialIndex={index} > <div className={`relative cursor-pointer ${cardClasses}`}> <Card className="flex-1 min-h-40 overflow-hidden rounded-lg border hover:shadow-xs transition-shadow duration-200"> <CardContent className="p-0"> {' '} {/* Adjusted padding */} {baseUrl && ( <div className="relative w-full aspect-video bg-muted"> <Image src={baseUrl} alt={`Thumbnail for ${video.title}`} fill sizes={ displayMode === 'chat' ? '(max-width: 768px) 50vw, 25vw' : '(max-width: 639px) 300px, 250px' } // Different sizes per mode className="object-cover" priority={index < 4} onError={e => { const target = e.target as HTMLImageElement target.src = '/images/placeholder-image.png' }} /> </div> )} <div className="p-2"> {' '} {/* Inner padding for text */} <p className="text-xs line-clamp-2 mb-1 font-semibold"> {video.title} </p> <div className="flex items-center space-x-2"> <Avatar className="h-4 w-4"> <AvatarImage src={`https://www.google.com/s2/favicons?domain=${ new URL(video.link).hostname }`} alt={video.channel || video.source} /> <AvatarFallback> {new URL(video.link).hostname[0]} </AvatarFallback> </Avatar> <div className="text-xs text-muted-foreground opacity-60 truncate"> {/* Display channel or source if available */} {video.channel || video.source || new URL(video.link).hostname} </div> </div> </div> </CardContent> </Card> {showOverlay && ( <div className="absolute inset-0 bg-black/30 rounded-md flex items-center justify-center text-white/80 text-sm"> <PlusCircle size={24} /> </div> )} </div> </VideoCarouselDialog> ) })} </div> ) } ================================================ FILE: components/video-search-results.tsx ================================================ /* eslint-disable @next/next/no-img-element */ 'use client' import { SerperSearchResultItem, SerperSearchResults } from '@/lib/types' import { VideoResultGrid } from './video-result-grid' export interface VideoSearchResultsProps { results: SerperSearchResults displayMode?: 'chat' | 'artifact' } // Utility function to ensure searchParameters are present export function createVideoSearchResults( searchResults: any, query: string | undefined ): SerperSearchResults { return { ...searchResults, videos: searchResults.videos || [], searchParameters: searchResults.searchParameters || { q: query || '', type: 'video', engine: 'google' } } } export function VideoSearchResults({ results, displayMode = 'chat' }: VideoSearchResultsProps) { const videos = results.videos.filter((video: SerperSearchResultItem) => { try { return new URL(video.link).pathname === '/watch' } catch (e) { console.error('Invalid video URL:', video.link) return false } }) const query = results.searchParameters?.q || '' if (!videos || videos.length === 0) { return <div className="text-muted-foreground">No videos found</div> } return ( <VideoResultGrid videos={videos} query={query} displayMode={displayMode} /> ) } ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: config/models/cloud.json ================================================ { "version": 1, "models": { "byMode": { "quick": { "speed": { "id": "gemini-3.1-flash-lite-preview", "name": "Gemini 3.1 Flash Lite Preview", "provider": "Google", "providerId": "google", "providerOptions": { "google": { "thinkingConfig": { "includeThoughts": true } } } }, "quality": { "id": "gemini-3.1-pro-preview", "name": "Gemini 3.1 Pro Preview", "provider": "Google", "providerId": "google", "providerOptions": { "google": { "thinkingConfig": { "includeThoughts": true } } } } }, "adaptive": { "speed": { "id": "gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", "provider": "Google", "providerId": "google", "providerOptions": { "google": { "thinkingConfig": { "includeThoughts": true } } } }, "quality": { "id": "gpt-5.2", "name": "GPT-5.2", "provider": "OpenAI", "providerId": "openai", "providerOptions": { "openai": { "reasoningEffort": "medium", "reasoningSummary": "auto" } } } } }, "relatedQuestions": { "id": "gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash Lite", "provider": "Google", "providerId": "google" } } } ================================================ FILE: config/models/default.json ================================================ { "version": 1, "models": { "byMode": { "quick": { "speed": { "id": "gpt-5-mini", "name": "GPT-5 mini", "provider": "OpenAI", "providerId": "openai", "providerOptions": { "openai": { "reasoningEffort": "low", "reasoningSummary": "auto" } } }, "quality": { "id": "gpt-5.2", "name": "GPT-5.2", "provider": "OpenAI", "providerId": "openai", "providerOptions": { "openai": { "reasoningEffort": "low", "reasoningSummary": "auto" } } } }, "adaptive": { "speed": { "id": "gpt-5-mini", "name": "GPT-5 mini", "provider": "OpenAI", "providerId": "openai", "providerOptions": { "openai": { "reasoningEffort": "medium", "reasoningSummary": "auto" } } }, "quality": { "id": "gpt-5.2", "name": "GPT-5.2", "provider": "OpenAI", "providerId": "openai", "providerOptions": { "openai": { "reasoningEffort": "medium", "reasoningSummary": "auto" } } } } }, "relatedQuestions": { "id": "gpt-4.1", "name": "GPT-4.1", "provider": "OpenAI", "providerId": "openai" } } } ================================================ FILE: docker-compose.yaml ================================================ # Docker Compose configuration for the morphic-stack development environment name: morphic-stack services: morphic: image: ghcr.io/${GITHUB_REPOSITORY:-your-username/morphic}:latest command: bun start -H 0.0.0.0 build: context: . dockerfile: Dockerfile cache_from: - morphic:builder - morphic:latest env_file: .env.local # Load environment variables environment: # Database connection for Docker Compose DATABASE_URL: postgresql://${POSTGRES_USER:-morphic}:${POSTGRES_PASSWORD:-morphic}@postgres:5432/${POSTGRES_DB:-morphic} DATABASE_SSL_DISABLED: 'true' # Disable SSL for local Docker PostgreSQL # Authentication settings (default: disabled for personal Docker use) ENABLE_AUTH: ${ENABLE_AUTH:-false} ANONYMOUS_USER_ID: ${ANONYMOUS_USER_ID:-docker-anonymous} ports: - '3000:3000' # Maps port 3000 on the host to port 3000 in the container. depends_on: postgres: condition: service_healthy redis: condition: service_started searxng: condition: service_started postgres: image: postgres:17-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-morphic} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-morphic} POSTGRES_DB: ${POSTGRES_DB:-morphic} ports: - '${POSTGRES_PORT:-5432}:5432' volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-morphic}'] interval: 10s timeout: 5s retries: 5 redis: image: redis:alpine ports: - '6379:6379' volumes: - redis_data:/data command: redis-server --appendonly yes searxng: image: searxng/searxng ports: - '${SEARXNG_PORT:-8080}:8080' env_file: .env.local # can remove if you want to use env variables or in settings.yml volumes: - ./searxng-limiter.toml:/etc/searxng/limiter.toml - ./searxng-settings.yml:/etc/searxng/settings.yml - searxng_data:/data volumes: postgres_data: redis_data: searxng_data: ================================================ FILE: docs/CONFIGURATION.md ================================================ # Configuration Guide This guide covers the optional features and their configuration in Morphic. ## Table of Contents - [Database](#database) - [Authentication](#authentication) - [Guest Mode](#guest-mode) - [Search Providers](#search-providers) - [Additional AI Providers](#additional-ai-providers) - [Other Features](#other-features) ## Database Morphic uses PostgreSQL for chat history storage. A database is **optional** for basic usage — without it, Morphic runs in a stateless mode where chat history is not persisted. ### Setting Up PostgreSQL Set the connection string in `.env.local`: ```bash DATABASE_URL=postgresql://user:password@localhost:5432/morphic ``` Any PostgreSQL provider works: [Neon](https://neon.tech/), [Supabase](https://supabase.com/), or a local PostgreSQL instance. ### Running Migrations After configuring your database, run migrations to create the necessary tables: ```bash bun run migrate ``` This command applies all migrations from the `drizzle/` directory. ## Authentication By default, Morphic runs in **anonymous mode** with authentication disabled (`ENABLE_AUTH=false` in `.env.local.example`). This is ideal for personal use where all users share a single anonymous user ID. ### Anonymous Mode (Default) No configuration needed. The default settings in `.env.local.example` include: ```bash ENABLE_AUTH=false ANONYMOUS_USER_ID=anonymous-user ``` **⚠️ Important**: Anonymous mode is only suitable for personal, single-user environments. All chat history is shared under one user ID. ### Enabling Supabase Authentication To enable user authentication with Supabase: 1. Create a Supabase project at [supabase.com](https://supabase.com) 2. Set the following environment variables in `.env.local`: ```bash ENABLE_AUTH=true NEXT_PUBLIC_SUPABASE_URL=[YOUR_SUPABASE_PROJECT_URL] NEXT_PUBLIC_SUPABASE_ANON_KEY=[YOUR_SUPABASE_ANON_KEY] ``` 3. Obtain your credentials from the Supabase dashboard: - **Project URL**: Settings → API → Project URL - **Anon Key**: Settings → API → Project API keys → anon/public With authentication enabled, users will need to sign up/login to use Morphic, and each user will have isolated chat history. ## Guest Mode Guest mode allows users to try Morphic without creating an account. Guest sessions are ephemeral - no chat history is stored in the database. ### Enabling Guest Mode Set the following environment variable: ```bash ENABLE_GUEST_CHAT=true ``` ### Guest Mode Behavior When guest mode is enabled: - **No authentication required**: Users can start chatting immediately - **No chat history**: Messages are not saved to the database - **Full context per request**: The client sends all messages with each request to maintain conversation context - **No URL navigation**: Guests stay on the root path (no `/search/[id]` URLs) - **Disabled features**: - File upload - Quality mode (forced to "Speed") - Chat sharing - Sidebar history ### Rate Limiting for Guests For cloud deployments, you can limit guest usage per IP address: ```bash GUEST_CHAT_DAILY_LIMIT=10 # Maximum requests per IP per day (default: 10) ``` Rate limiting requires Redis (Upstash) configuration: ```bash UPSTASH_REDIS_REST_URL=[YOUR_UPSTASH_URL] UPSTASH_REDIS_REST_TOKEN=[YOUR_UPSTASH_TOKEN] ``` **Note**: Rate limiting only applies when `MORPHIC_CLOUD_DEPLOYMENT=true`. For self-hosted deployments, rate limiting is disabled by default. ### Recommended Setup | Environment | Configuration | | -------------- | -------------------------------------------- | | Personal/Local | `ENABLE_AUTH=false` (anonymous mode) | | Public Demo | `ENABLE_GUEST_CHAT=true` with rate limiting | | Production | `ENABLE_AUTH=true` (Supabase authentication) | ## Search Providers ### SearXNG Configuration SearXNG can be used as an alternative search backend with advanced search capabilities. #### Basic Setup 1. Set up SearXNG as your search provider: ```bash SEARCH_API=searxng SEARXNG_API_URL=http://localhost:8080 SEARXNG_SECRET="" # generate with: openssl rand -base64 32 ``` #### Docker Setup 1. Ensure you have Docker and Docker Compose installed 2. Two configuration files are provided in the root directory: - `searxng-settings.yml`: Contains main configuration for SearXNG - `searxng-limiter.toml`: Configures rate limiting and bot detection #### Advanced Configuration 1. Configure environment variables in your `.env.local`: ```bash # SearXNG Base Configuration SEARXNG_PORT=8080 SEARXNG_BIND_ADDRESS=0.0.0.0 SEARXNG_IMAGE_PROXY=true # Search Behavior SEARXNG_DEFAULT_DEPTH=basic # Set to 'basic' or 'advanced' SEARXNG_MAX_RESULTS=50 # Maximum number of results to return SEARXNG_ENGINES=google,bing,duckduckgo,wikipedia # Comma-separated list of search engines SEARXNG_TIME_RANGE=None # Time range: day, week, month, year, or None SEARXNG_SAFESEARCH=0 # 0: off, 1: moderate, 2: strict # Rate Limiting SEARXNG_LIMITER=false # Enable to limit requests per IP ``` #### Advanced Search Features - `SEARXNG_DEFAULT_DEPTH`: Controls search depth - `basic`: Standard search - `advanced`: Includes content crawling and relevance scoring - `SEARXNG_MAX_RESULTS`: Maximum results to return - `SEARXNG_CRAWL_MULTIPLIER`: In advanced mode, determines how many results to crawl - Example: If `MAX_RESULTS=10` and `CRAWL_MULTIPLIER=4`, up to 40 results will be crawled #### Customizing SearXNG You can modify `searxng-settings.yml` to: - Enable/disable specific search engines - Change UI settings - Adjust server options Example of disabling specific engines: ```yaml engines: - name: wikidata disabled: true ``` For detailed configuration options, refer to the [SearXNG documentation](https://docs.searxng.org/admin/settings/settings.html#settings-yml) #### Troubleshooting - If specific search engines aren't working, try disabling them in `searxng-settings.yml` - For rate limiting issues, adjust settings in `searxng-limiter.toml` - Check Docker logs for potential configuration errors: ```bash docker-compose logs searxng ``` ### Brave Search (Optional) Brave Search provides enhanced support for video and image searches when used as a general search provider: ```bash BRAVE_SEARCH_API_KEY=[YOUR_BRAVE_SEARCH_API_KEY] ``` Get your API key at: https://brave.com/search/api/ **Features:** - Multiple content types in single search (web, video, image, news) - Optimized for multimedia content with thumbnails - Direct video duration and metadata support - Used automatically when `type="general"` is specified in search queries **Fallback Behavior:** If `BRAVE_SEARCH_API_KEY` is not configured, `type="general"` searches will automatically fall back to your configured optimized search provider. Video and image searches will still work but may have limited multimedia support depending on the provider. ## Additional AI Providers Models are configured in `config/models/*.json` files. Each provider requires its corresponding API key to be set in the environment variables. ### Model Configuration Model configuration files use the following structure: ```json { "version": 1, "models": { "byMode": { "quick": { "speed": { "id": "model-id", "name": "Model Name", "provider": "Provider Name", "providerId": "provider-id", "providerOptions": {} }, "quality": { "id": "model-id", "name": "Model Name", "provider": "Provider Name", "providerId": "provider-id", "providerOptions": {} } }, "adaptive": { "speed": { "id": "model-id", "name": "Model Name", "provider": "Provider Name", "providerId": "provider-id", "providerOptions": {} }, "quality": { "id": "model-id", "name": "Model Name", "provider": "Provider Name", "providerId": "provider-id", "providerOptions": {} } } }, "relatedQuestions": { "id": "model-id", "name": "Model Name", "provider": "Provider Name", "providerId": "provider-id" } } } ``` Define all four combinations to control which model runs for every search mode (`quick`, `adaptive`) and preference (`speed`, `quality`). For example, you can pair `quick/speed` with `gemini-2.5-flash-lite` while keeping `adaptive/quality` on GPT-5. The default config ships with OpenAI models for every slot so Morphic works out-of-the-box. ### Supported Providers #### OpenAI (Default) ```bash OPENAI_API_KEY=[YOUR_API_KEY] ``` #### Google Generative AI ```bash GOOGLE_GENERATIVE_AI_API_KEY=[YOUR_API_KEY] ``` #### Anthropic ```bash ANTHROPIC_API_KEY=[YOUR_API_KEY] ``` #### Vercel AI Gateway [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) allows you to use multiple AI providers through a single endpoint with automatic failover and load balancing. ```bash AI_GATEWAY_API_KEY=[YOUR_AI_GATEWAY_API_KEY] ``` #### Ollama [Ollama](https://ollama.com/) enables you to run large language models locally on your own hardware. **Configuration:** ```bash OLLAMA_BASE_URL=http://localhost:11434 ``` Then update your `config/models/*.json` files to use Ollama models: ```json { "id": "qwen3:latest", "name": "Qwen 3", "provider": "Ollama", "providerId": "ollama" } ``` **Important Notes:** - **Tools Capability**: Morphic requires models to support the `tools` capability for function calling. On server startup, Morphic validates configured models and logs the results. Note that even if a model reports tools support, actual tool calling performance depends on the model's capabilities and is not guaranteed. - **Validation Logs**: Check server logs on startup to verify your configured models: ``` ✓ qwen3:latest (configured and tools supported) ✗ deepseek-r1:latest (configured but lacks tools support) ``` ## Other Features ### LLM Observability Enable tracing and monitoring with Langfuse: ```bash LANGFUSE_SECRET_KEY=[YOUR_SECRET_KEY] LANGFUSE_PUBLIC_KEY=[YOUR_PUBLIC_KEY] LANGFUSE_HOST=https://cloud.langfuse.com ``` ### File Upload Enable file upload with Cloudflare R2: ```bash CLOUDFLARE_R2_ACCESS_KEY_ID=[YOUR_ACCESS_KEY] CLOUDFLARE_R2_SECRET_ACCESS_KEY=[YOUR_SECRET_KEY] CLOUDFLARE_R2_ACCOUNT_ID=[YOUR_ACCOUNT_ID] CLOUDFLARE_R2_BUCKET_NAME=[YOUR_BUCKET_NAME] ``` ### Alternative Fetch Tool Use Jina for enhanced content extraction: ```bash JINA_API_KEY=[YOUR_API_KEY] ``` ================================================ FILE: docs/DOCKER.md ================================================ # Docker Guide This guide covers running Morphic with Docker, including development setup, prebuilt images, and deployment options. ## Quick Start with Docker Compose 1. Configure environment variables: ```bash cp .env.local.example .env.local ``` Edit `.env.local` and set the required variables: ```bash DATABASE_URL=postgresql://morphic:morphic@postgres:5432/morphic OPENAI_API_KEY=your_openai_key TAVILY_API_KEY=your_tavily_key BRAVE_SEARCH_API_KEY=your_brave_key ``` **Note**: Authentication is disabled by default (`ENABLE_AUTH=false` in `.env.local.example`). **Optional**: Customize PostgreSQL credentials by setting environment variables in `.env.local`: ```bash POSTGRES_USER=morphic # Default: morphic POSTGRES_PASSWORD=morphic # Default: morphic POSTGRES_DB=morphic # Default: morphic POSTGRES_PORT=5432 # Default: 5432 ``` 2. Start the Docker containers: ```bash docker compose up -d ``` The application will: - Start PostgreSQL 17 with health checks - Start Redis for SearXNG search caching - Wait for the database to be ready - Run database migrations automatically - Start the Morphic application - Start SearXNG (optional search provider) 3. Visit http://localhost:3000 in your browser. **Note**: Database data is persisted in a Docker volume. To reset the database, run: ```bash docker compose down -v # This will delete all data ``` ## Using Prebuilt Image Prebuilt Docker images are automatically built and published to GitHub Container Registry: ```bash docker pull ghcr.io/miurla/morphic:latest ``` You can use it with docker-compose by setting the image in your `docker-compose.yaml`: ```yaml services: morphic: image: ghcr.io/miurla/morphic:latest env_file: .env.local environment: DATABASE_URL: postgresql://morphic:morphic@postgres:5432/morphic DATABASE_SSL_DISABLED: 'true' ENABLE_AUTH: 'false' ports: - '3000:3000' depends_on: - postgres - redis ``` **Note**: The prebuilt image runs in **anonymous mode only** (`ENABLE_AUTH=false`). Supabase authentication cannot be enabled because `NEXT_PUBLIC_*` environment variables are embedded at build time by Next.js. To enable authentication or customize model configurations, you need to build from source — see [CONFIGURATION.md](./CONFIGURATION.md) for details. ## Building from Source Use Docker Compose for a complete setup with PostgreSQL, Redis, and SearXNG. See the [Quick Start](#quick-start-with-docker-compose) section above. ## Useful Commands ```bash # Start all containers in background docker compose up -d # Stop all containers docker compose down # Stop all containers and remove volumes (deletes database data) docker compose down -v # View logs docker compose logs -f morphic # Rebuild the image docker compose build morphic ``` ================================================ FILE: drizzle/0000_black_lifeguard.sql ================================================ CREATE TABLE "chats" ( "id" varchar(191) PRIMARY KEY NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "title" text NOT NULL, "user_id" varchar(255) NOT NULL, "visibility" varchar(256) DEFAULT 'private' NOT NULL ); --> statement-breakpoint CREATE TABLE "messages" ( "id" varchar(191) PRIMARY KEY NOT NULL, "chat_id" varchar(191) NOT NULL, "role" varchar(256) NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE "parts" ( "id" varchar(191) PRIMARY KEY NOT NULL, "message_id" varchar(191) NOT NULL, "order" integer NOT NULL, "type" varchar(256) NOT NULL, "text_text" text, "reasoning_text" text, "file_media_type" varchar(256), "file_filename" varchar(1024), "file_url" text, "source_url_source_id" varchar(256), "source_url_url" text, "source_url_title" text, "source_document_source_id" varchar(256), "source_document_media_type" varchar(256), "source_document_title" text, "source_document_filename" varchar(1024), "source_document_url" text, "source_document_snippet" text, "tool_tool_call_id" varchar(256), "tool_state" varchar(256), "tool_error_text" text, "tool_search_input" json, "tool_search_output" json, "tool_fetch_input" json, "tool_fetch_output" json, "tool_question_input" json, "tool_question_output" json, "tool_todoWrite_input" json, "tool_todoWrite_output" json, "tool_todoRead_input" json, "tool_todoRead_output" json, "tool_dynamic_input" json, "tool_dynamic_output" json, "tool_dynamic_name" varchar(256), "tool_dynamic_type" varchar(256), "data_prefix" varchar(256), "data_content" json, "data_id" varchar(256), "provider_metadata" json, "created_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "text_text_required" CHECK ((type != 'text' OR text_text IS NOT NULL)), CONSTRAINT "reasoning_text_required" CHECK ((type != 'reasoning' OR reasoning_text IS NOT NULL)), CONSTRAINT "file_fields_required" CHECK ((type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))), CONSTRAINT "tool_state_valid" CHECK ((tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))), CONSTRAINT "tool_fields_required" CHECK ((type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))) ); --> statement-breakpoint ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chats"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "parts" ADD CONSTRAINT "parts_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "messages_chat_id_idx" ON "messages" USING btree ("chat_id");--> statement-breakpoint CREATE INDEX "messages_chat_id_created_at_idx" ON "messages" USING btree ("chat_id","created_at");--> statement-breakpoint CREATE INDEX "parts_message_id_idx" ON "parts" USING btree ("message_id");--> statement-breakpoint CREATE INDEX "parts_message_id_order_idx" ON "parts" USING btree ("message_id","order"); ================================================ FILE: drizzle/0001_thin_supreme_intelligence.sql ================================================ ALTER TABLE "messages" ADD COLUMN "updated_at" timestamp;--> statement-breakpoint ALTER TABLE "messages" ADD COLUMN "metadata" json; ================================================ FILE: drizzle/0002_material_crystal.sql ================================================ ALTER TABLE "messages" ALTER COLUMN "metadata" SET DATA TYPE jsonb; ================================================ FILE: drizzle/0003_heavy_whirlwind.sql ================================================ CREATE TABLE "feedback" ( "id" varchar(191) PRIMARY KEY NOT NULL, "user_id" varchar(255), "sentiment" varchar(256) NOT NULL, "message" text NOT NULL, "page_url" text NOT NULL, "user_agent" text, "created_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE INDEX "feedback_user_id_idx" ON "feedback" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "feedback_created_at_idx" ON "feedback" USING btree ("created_at"); ================================================ FILE: drizzle/0004_natural_wallow.sql ================================================ CREATE INDEX "chats_user_id_idx" ON "chats" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "chats_user_id_created_at_idx" ON "chats" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint CREATE INDEX "chats_created_at_idx" ON "chats" USING btree ("created_at" DESC NULLS LAST); ================================================ FILE: drizzle/0005_awesome_riptide.sql ================================================ ALTER TABLE "chats" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "feedback" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "messages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "parts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint CREATE POLICY "anyone_can_insert_feedback" ON "feedback" AS PERMISSIVE FOR INSERT TO public WITH CHECK (true); ================================================ FILE: drizzle/0006_brainy_wrecking_crew.sql ================================================ CREATE POLICY "users_manage_own_chats" ON "chats" AS PERMISSIVE FOR ALL TO public USING (user_id = current_setting('app.current_user_id', true)) WITH CHECK (user_id = current_setting('app.current_user_id', true));--> statement-breakpoint CREATE POLICY "users_manage_chat_messages" ON "messages" AS PERMISSIVE FOR ALL TO public USING (EXISTS ( SELECT 1 FROM "chats" WHERE "chats".id = chat_id AND "chats".user_id = current_setting('app.current_user_id', true) )) WITH CHECK (EXISTS ( SELECT 1 FROM "chats" WHERE "chats".id = chat_id AND "chats".user_id = current_setting('app.current_user_id', true) ));--> statement-breakpoint CREATE POLICY "users_manage_message_parts" ON "parts" AS PERMISSIVE FOR ALL TO public USING (EXISTS ( SELECT 1 FROM "messages" INNER JOIN "chats" ON "chats".id = "messages".chat_id WHERE "messages".id = message_id AND "chats".user_id = current_setting('app.current_user_id', true) )) WITH CHECK (EXISTS ( SELECT 1 FROM "messages" INNER JOIN "chats" ON "chats".id = "messages".chat_id WHERE "messages".id = message_id AND "chats".user_id = current_setting('app.current_user_id', true) )); ================================================ FILE: drizzle/0007_illegal_mephistopheles.sql ================================================ CREATE POLICY "public_chats_readable" ON "chats" AS PERMISSIVE FOR SELECT TO public USING (visibility = 'public');--> statement-breakpoint CREATE POLICY "public_chat_messages_readable" ON "messages" AS PERMISSIVE FOR SELECT TO public USING (EXISTS ( SELECT 1 FROM "chats" WHERE "chats".id = chat_id AND "chats".visibility = 'public' ));--> statement-breakpoint CREATE POLICY "public_chat_parts_readable" ON "parts" AS PERMISSIVE FOR SELECT TO public USING (EXISTS ( SELECT 1 FROM "messages" INNER JOIN "chats" ON "chats".id = "messages".chat_id WHERE "messages".id = message_id AND "chats".visibility = 'public' )); ================================================ FILE: drizzle/0008_glamorous_riptide.sql ================================================ ALTER TABLE "feedback" ENABLE ROW LEVEL SECURITY; ================================================ FILE: drizzle/0009_thankful_may_parker.sql ================================================ CREATE INDEX "chats_id_user_id_idx" ON "chats" USING btree ("id","user_id"); ================================================ FILE: drizzle/0010_lonely_kang.sql ================================================ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'feedback' AND policyname = 'feedback_select_policy' ) THEN CREATE POLICY "feedback_select_policy" ON "feedback" AS PERMISSIVE FOR SELECT TO public USING (true); END IF; END $$; ================================================ FILE: drizzle/meta/0000_snapshot.json ================================================ { "id": "bd2133a6-1281-44a1-8619-c1f0407dfb77", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0001_snapshot.json ================================================ { "id": "90075b6a-dc27-4224-beb5-1ce34abc7fc5", "prevId": "bd2133a6-1281-44a1-8619-c1f0407dfb77", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "json", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0002_snapshot.json ================================================ { "id": "8e9de712-037f-4132-8f06-b3d2f57dd68a", "prevId": "90075b6a-dc27-4224-beb5-1ce34abc7fc5", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0003_snapshot.json ================================================ { "id": "c9b8dd61-7cb9-4f3b-9e5f-811592e56563", "prevId": "8e9de712-037f-4132-8f06-b3d2f57dd68a", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0004_snapshot.json ================================================ { "id": "28e1690d-994a-4027-abe2-5f283bb4c720", "prevId": "c9b8dd61-7cb9-4f3b-9e5f-811592e56563", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0005_snapshot.json ================================================ { "id": "c2240142-0d69-482e-b6e0-a9561c421aa2", "prevId": "28e1690d-994a-4027-abe2-5f283bb4c720", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0006_snapshot.json ================================================ { "id": "60e18ccb-ad03-4315-bee3-286b827d26fe", "prevId": "c2240142-0d69-482e-b6e0-a9561c421aa2", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_own_chats": { "name": "users_manage_own_chats", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "user_id = current_setting('app.current_user_id', true)", "withCheck": "user_id = current_setting('app.current_user_id', true)" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_chat_messages": { "name": "users_manage_chat_messages", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_message_parts": { "name": "users_manage_message_parts", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" } }, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0007_snapshot.json ================================================ { "id": "a1b15544-fb28-4c05-82c2-8e81ea3d5689", "prevId": "60e18ccb-ad03-4315-bee3-286b827d26fe", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_own_chats": { "name": "users_manage_own_chats", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "user_id = current_setting('app.current_user_id', true)", "withCheck": "user_id = current_setting('app.current_user_id', true)" }, "public_chats_readable": { "name": "public_chats_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "visibility = 'public'" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": false }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_chat_messages": { "name": "users_manage_chat_messages", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_messages_readable": { "name": "public_chat_messages_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_message_parts": { "name": "users_manage_message_parts", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_parts_readable": { "name": "public_chat_parts_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0008_snapshot.json ================================================ { "id": "c631d669-f529-4fda-8f77-1db7cc5310e4", "prevId": "a1b15544-fb28-4c05-82c2-8e81ea3d5689", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_own_chats": { "name": "users_manage_own_chats", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "user_id = current_setting('app.current_user_id', true)", "withCheck": "user_id = current_setting('app.current_user_id', true)" }, "public_chats_readable": { "name": "public_chats_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "visibility = 'public'" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_chat_messages": { "name": "users_manage_chat_messages", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_messages_readable": { "name": "public_chat_messages_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_message_parts": { "name": "users_manage_message_parts", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_parts_readable": { "name": "public_chat_parts_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0009_snapshot.json ================================================ { "id": "09bf63d0-855c-4ef2-85b7-9f63220f2566", "prevId": "c631d669-f529-4fda-8f77-1db7cc5310e4", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_id_user_id_idx": { "name": "chats_id_user_id_idx", "columns": [ { "expression": "id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_own_chats": { "name": "users_manage_own_chats", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "user_id = current_setting('app.current_user_id', true)", "withCheck": "user_id = current_setting('app.current_user_id', true)" }, "public_chats_readable": { "name": "public_chats_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "visibility = 'public'" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_chat_messages": { "name": "users_manage_chat_messages", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_messages_readable": { "name": "public_chat_messages_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_message_parts": { "name": "users_manage_message_parts", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_parts_readable": { "name": "public_chat_parts_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/0010_snapshot.json ================================================ { "id": "413889c5-099c-4a0c-8a67-b5b1d0605366", "prevId": "09bf63d0-855c-4ef2-85b7-9f63220f2566", "version": "7", "dialect": "postgresql", "tables": { "public.chats": { "name": "chats", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "title": { "name": "title", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "visibility": { "name": "visibility", "type": "varchar(256)", "primaryKey": false, "notNull": true, "default": "'private'" } }, "indexes": { "chats_user_id_idx": { "name": "chats_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_user_id_created_at_idx": { "name": "chats_user_id_created_at_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_created_at_idx": { "name": "chats_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": false, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "chats_id_user_id_idx": { "name": "chats_id_user_id_idx", "columns": [ { "expression": "id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_own_chats": { "name": "users_manage_own_chats", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "user_id = current_setting('app.current_user_id', true)", "withCheck": "user_id = current_setting('app.current_user_id', true)" }, "public_chats_readable": { "name": "public_chats_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "visibility = 'public'" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.feedback": { "name": "feedback", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "varchar(255)", "primaryKey": false, "notNull": false }, "sentiment": { "name": "sentiment", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "message": { "name": "message", "type": "text", "primaryKey": false, "notNull": true }, "page_url": { "name": "page_url", "type": "text", "primaryKey": false, "notNull": true }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "feedback_user_id_idx": { "name": "feedback_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "feedback_created_at_idx": { "name": "feedback_created_at_idx", "columns": [ { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "feedback_select_policy": { "name": "feedback_select_policy", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "true" }, "anyone_can_insert_feedback": { "name": "anyone_can_insert_feedback", "as": "PERMISSIVE", "for": "INSERT", "to": ["public"], "withCheck": "true" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.messages": { "name": "messages", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "chat_id": { "name": "chat_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false } }, "indexes": { "messages_chat_id_idx": { "name": "messages_chat_id_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "messages_chat_id_created_at_idx": { "name": "messages_chat_id_created_at_idx", "columns": [ { "expression": "chat_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "messages_chat_id_chats_id_fk": { "name": "messages_chat_id_chats_id_fk", "tableFrom": "messages", "tableTo": "chats", "columnsFrom": ["chat_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_chat_messages": { "name": "users_manage_chat_messages", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_messages_readable": { "name": "public_chat_messages_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"chats\"\n WHERE \"chats\".id = chat_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": {}, "isRLSEnabled": true }, "public.parts": { "name": "parts", "schema": "", "columns": { "id": { "name": "id", "type": "varchar(191)", "primaryKey": true, "notNull": true }, "message_id": { "name": "message_id", "type": "varchar(191)", "primaryKey": false, "notNull": true }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, "type": { "name": "type", "type": "varchar(256)", "primaryKey": false, "notNull": true }, "text_text": { "name": "text_text", "type": "text", "primaryKey": false, "notNull": false }, "reasoning_text": { "name": "reasoning_text", "type": "text", "primaryKey": false, "notNull": false }, "file_media_type": { "name": "file_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "file_filename": { "name": "file_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "file_url": { "name": "file_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_source_id": { "name": "source_url_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_url_url": { "name": "source_url_url", "type": "text", "primaryKey": false, "notNull": false }, "source_url_title": { "name": "source_url_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_source_id": { "name": "source_document_source_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_media_type": { "name": "source_document_media_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "source_document_title": { "name": "source_document_title", "type": "text", "primaryKey": false, "notNull": false }, "source_document_filename": { "name": "source_document_filename", "type": "varchar(1024)", "primaryKey": false, "notNull": false }, "source_document_url": { "name": "source_document_url", "type": "text", "primaryKey": false, "notNull": false }, "source_document_snippet": { "name": "source_document_snippet", "type": "text", "primaryKey": false, "notNull": false }, "tool_tool_call_id": { "name": "tool_tool_call_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_state": { "name": "tool_state", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_error_text": { "name": "tool_error_text", "type": "text", "primaryKey": false, "notNull": false }, "tool_search_input": { "name": "tool_search_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_search_output": { "name": "tool_search_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_input": { "name": "tool_fetch_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_fetch_output": { "name": "tool_fetch_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_input": { "name": "tool_question_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_question_output": { "name": "tool_question_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_input": { "name": "tool_todoWrite_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoWrite_output": { "name": "tool_todoWrite_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_input": { "name": "tool_todoRead_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_todoRead_output": { "name": "tool_todoRead_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_input": { "name": "tool_dynamic_input", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_output": { "name": "tool_dynamic_output", "type": "json", "primaryKey": false, "notNull": false }, "tool_dynamic_name": { "name": "tool_dynamic_name", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "tool_dynamic_type": { "name": "tool_dynamic_type", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_prefix": { "name": "data_prefix", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "data_content": { "name": "data_content", "type": "json", "primaryKey": false, "notNull": false }, "data_id": { "name": "data_id", "type": "varchar(256)", "primaryKey": false, "notNull": false }, "provider_metadata": { "name": "provider_metadata", "type": "json", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "parts_message_id_idx": { "name": "parts_message_id_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "parts_message_id_order_idx": { "name": "parts_message_id_order_idx", "columns": [ { "expression": "message_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "order", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "parts_message_id_messages_id_fk": { "name": "parts_message_id_messages_id_fk", "tableFrom": "parts", "tableTo": "messages", "columnsFrom": ["message_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": { "users_manage_message_parts": { "name": "users_manage_message_parts", "as": "PERMISSIVE", "for": "ALL", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )", "withCheck": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".user_id = current_setting('app.current_user_id', true)\n )" }, "public_chat_parts_readable": { "name": "public_chat_parts_readable", "as": "PERMISSIVE", "for": "SELECT", "to": ["public"], "using": "EXISTS (\n SELECT 1 FROM \"messages\"\n INNER JOIN \"chats\" ON \"chats\".id = \"messages\".chat_id\n WHERE \"messages\".id = message_id\n AND \"chats\".visibility = 'public'\n )" } }, "checkConstraints": { "text_text_required": { "name": "text_text_required", "value": "(type != 'text' OR text_text IS NOT NULL)" }, "reasoning_text_required": { "name": "reasoning_text_required", "value": "(type != 'reasoning' OR reasoning_text IS NOT NULL)" }, "file_fields_required": { "name": "file_fields_required", "value": "(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))" }, "tool_state_valid": { "name": "tool_state_valid", "value": "(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))" }, "tool_fields_required": { "name": "tool_fields_required", "value": "(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))" } }, "isRLSEnabled": true } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: drizzle/meta/_journal.json ================================================ { "version": "7", "dialect": "postgresql", "entries": [ { "idx": 0, "version": "7", "when": 1754303165189, "tag": "0000_black_lifeguard", "breakpoints": true }, { "idx": 1, "version": "7", "when": 1754819323396, "tag": "0001_thin_supreme_intelligence", "breakpoints": true }, { "idx": 2, "version": "7", "when": 1754822051273, "tag": "0002_material_crystal", "breakpoints": true }, { "idx": 3, "version": "7", "when": 1754832999921, "tag": "0003_heavy_whirlwind", "breakpoints": true }, { "idx": 4, "version": "7", "when": 1754894590844, "tag": "0004_natural_wallow", "breakpoints": true }, { "idx": 5, "version": "7", "when": 1755654069349, "tag": "0005_awesome_riptide", "breakpoints": true }, { "idx": 6, "version": "7", "when": 1755686097019, "tag": "0006_brainy_wrecking_crew", "breakpoints": true }, { "idx": 7, "version": "7", "when": 1755737289769, "tag": "0007_illegal_mephistopheles", "breakpoints": true }, { "idx": 8, "version": "7", "when": 1755739239041, "tag": "0008_glamorous_riptide", "breakpoints": true }, { "idx": 9, "version": "7", "when": 1755739927534, "tag": "0009_thankful_may_parker", "breakpoints": true }, { "idx": 10, "version": "7", "when": 1757242257993, "tag": "0010_lonely_kang", "breakpoints": true } ] } ================================================ FILE: drizzle/relations.ts ================================================ import { relations } from 'drizzle-orm/relations' import { chats, messages, parts } from './schema' export const messagesRelations = relations(messages, ({ one, many }) => ({ chat: one(chats, { fields: [messages.chatId], references: [chats.id] }), parts: many(parts) })) export const chatsRelations = relations(chats, ({ many }) => ({ messages: many(messages) })) export const partsRelations = relations(parts, ({ one }) => ({ message: one(messages, { fields: [parts.messageId], references: [messages.id] }) })) ================================================ FILE: drizzle/schema.ts ================================================ import { sql } from 'drizzle-orm' import { check, foreignKey, index, integer, json, jsonb, pgPolicy, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core' export const chats = pgTable( 'chats', { id: varchar({ length: 191 }).primaryKey().notNull(), createdAt: timestamp('created_at', { mode: 'string' }) .defaultNow() .notNull(), title: text().notNull(), userId: varchar('user_id', { length: 255 }).notNull(), visibility: varchar({ length: 256 }).default('private').notNull() }, table => [ index('chats_created_at_idx').using( 'btree', table.createdAt.desc().nullsLast().op('timestamp_ops') ), index('chats_id_user_id_idx').using( 'btree', table.id.asc().nullsLast().op('text_ops'), table.userId.asc().nullsLast().op('text_ops') ), index('chats_user_id_created_at_idx').using( 'btree', table.userId.asc().nullsLast().op('timestamp_ops'), table.createdAt.desc().nullsLast().op('text_ops') ), index('chats_user_id_idx').using( 'btree', table.userId.asc().nullsLast().op('text_ops') ), pgPolicy('public_chats_readable', { as: 'permissive', for: 'select', to: ['public'], using: sql`((visibility)::text = 'public'::text)` }), pgPolicy('users_manage_own_chats', { as: 'permissive', for: 'all', to: ['public'] }) ] ) export const messages = pgTable( 'messages', { id: varchar({ length: 191 }).primaryKey().notNull(), chatId: varchar('chat_id', { length: 191 }).notNull(), role: varchar({ length: 256 }).notNull(), createdAt: timestamp('created_at', { mode: 'string' }) .defaultNow() .notNull(), updatedAt: timestamp('updated_at', { mode: 'string' }), metadata: jsonb() }, table => [ index('messages_chat_id_created_at_idx').using( 'btree', table.chatId.asc().nullsLast().op('text_ops'), table.createdAt.asc().nullsLast().op('text_ops') ), index('messages_chat_id_idx').using( 'btree', table.chatId.asc().nullsLast().op('text_ops') ), foreignKey({ columns: [table.chatId], foreignColumns: [chats.id], name: 'messages_chat_id_chats_id_fk' }).onDelete('cascade'), pgPolicy('public_chat_messages_readable', { as: 'permissive', for: 'select', to: ['public'], using: sql`(EXISTS ( SELECT 1 FROM chats WHERE (((chats.id)::text = (messages.chat_id)::text) AND ((chats.visibility)::text = 'public'::text))))` }), pgPolicy('users_manage_chat_messages', { as: 'permissive', for: 'all', to: ['public'] }) ] ) export const parts = pgTable( 'parts', { id: varchar({ length: 191 }).primaryKey().notNull(), messageId: varchar('message_id', { length: 191 }).notNull(), order: integer().notNull(), type: varchar({ length: 256 }).notNull(), textText: text('text_text'), reasoningText: text('reasoning_text'), fileMediaType: varchar('file_media_type', { length: 256 }), fileFilename: varchar('file_filename', { length: 1024 }), fileUrl: text('file_url'), sourceUrlSourceId: varchar('source_url_source_id', { length: 256 }), sourceUrlUrl: text('source_url_url'), sourceUrlTitle: text('source_url_title'), sourceDocumentSourceId: varchar('source_document_source_id', { length: 256 }), sourceDocumentMediaType: varchar('source_document_media_type', { length: 256 }), sourceDocumentTitle: text('source_document_title'), sourceDocumentFilename: varchar('source_document_filename', { length: 1024 }), sourceDocumentUrl: text('source_document_url'), sourceDocumentSnippet: text('source_document_snippet'), toolToolCallId: varchar('tool_tool_call_id', { length: 256 }), toolState: varchar('tool_state', { length: 256 }), toolErrorText: text('tool_error_text'), toolSearchInput: json('tool_search_input'), toolSearchOutput: json('tool_search_output'), toolFetchInput: json('tool_fetch_input'), toolFetchOutput: json('tool_fetch_output'), toolQuestionInput: json('tool_question_input'), toolQuestionOutput: json('tool_question_output'), toolTodoWriteInput: json('tool_todoWrite_input'), toolTodoWriteOutput: json('tool_todoWrite_output'), toolTodoReadInput: json('tool_todoRead_input'), toolTodoReadOutput: json('tool_todoRead_output'), toolDynamicInput: json('tool_dynamic_input'), toolDynamicOutput: json('tool_dynamic_output'), toolDynamicName: varchar('tool_dynamic_name', { length: 256 }), toolDynamicType: varchar('tool_dynamic_type', { length: 256 }), dataPrefix: varchar('data_prefix', { length: 256 }), dataContent: json('data_content'), dataId: varchar('data_id', { length: 256 }), providerMetadata: json('provider_metadata'), createdAt: timestamp('created_at', { mode: 'string' }) .defaultNow() .notNull() }, table => [ index('parts_message_id_idx').using( 'btree', table.messageId.asc().nullsLast().op('text_ops') ), index('parts_message_id_order_idx').using( 'btree', table.messageId.asc().nullsLast().op('int4_ops'), table.order.asc().nullsLast().op('int4_ops') ), foreignKey({ columns: [table.messageId], foreignColumns: [messages.id], name: 'parts_message_id_messages_id_fk' }).onDelete('cascade'), pgPolicy('public_chat_parts_readable', { as: 'permissive', for: 'select', to: ['public'], using: sql`(EXISTS ( SELECT 1 FROM (messages JOIN chats ON (((chats.id)::text = (messages.chat_id)::text))) WHERE (((messages.id)::text = (parts.message_id)::text) AND ((chats.visibility)::text = 'public'::text))))` }), pgPolicy('users_manage_message_parts', { as: 'permissive', for: 'all', to: ['public'] }), check( 'text_text_required', sql`((type)::text <> 'text'::text) OR (text_text IS NOT NULL)` ), check( 'reasoning_text_required', sql`((type)::text <> 'reasoning'::text) OR (reasoning_text IS NOT NULL)` ), check( 'file_fields_required', sql`((type)::text <> 'file'::text) OR ((file_media_type IS NOT NULL) AND (file_filename IS NOT NULL) AND (file_url IS NOT NULL))` ), check( 'tool_state_valid', sql`(tool_state IS NULL) OR ((tool_state)::text = ANY ((ARRAY['input-streaming'::character varying, 'input-available'::character varying, 'output-available'::character varying, 'output-error'::character varying])::text[]))` ), check( 'tool_fields_required', sql`((type)::text !~~ 'tool-%'::text) OR ((tool_tool_call_id IS NOT NULL) AND (tool_state IS NOT NULL))` ) ] ) export const feedback = pgTable( 'feedback', { id: varchar({ length: 191 }).primaryKey().notNull(), userId: varchar('user_id', { length: 255 }), sentiment: varchar({ length: 256 }).notNull(), message: text().notNull(), pageUrl: text('page_url').notNull(), userAgent: text('user_agent'), createdAt: timestamp('created_at', { mode: 'string' }) .defaultNow() .notNull() }, table => [ index('feedback_created_at_idx').using( 'btree', table.createdAt.asc().nullsLast().op('timestamp_ops') ), index('feedback_user_id_idx').using( 'btree', table.userId.asc().nullsLast().op('text_ops') ), pgPolicy('feedback_select_policy', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }), pgPolicy('feedback_insert_policy', { as: 'permissive', for: 'insert', to: ['public'] }) ] ) ================================================ FILE: drizzle.config.ts ================================================ import * as dotenv from 'dotenv' import { defineConfig } from 'drizzle-kit' import 'dotenv/config' // Load from .env.local if DATABASE_URL is not set if (!process.env.DATABASE_URL) { dotenv.config({ path: '.env.local' }) } export default defineConfig({ schema: './lib/db/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL! } }) ================================================ FILE: hooks/use-auth-check.tsx ================================================ 'use client' import { useEffect, useState } from 'react' import { User } from '@supabase/supabase-js' import { createClient } from '@/lib/supabase/client' export function useAuthCheck() { const [user, setUser] = useState<User | null>(null) const [loading, setLoading] = useState(true) useEffect(() => { let subscription: { unsubscribe: () => void } | null = null const checkAuth = async () => { try { const supabase = createClient() const { data: { session } } = await supabase.auth.getSession() setUser(session?.user ?? null) // Subscribe to auth changes const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange((event, session) => { setUser(session?.user ?? null) }) subscription = authSubscription } catch (error) { // Supabase not configured setUser(null) } finally { setLoading(false) } } checkAuth() return () => { subscription?.unsubscribe() } }, []) return { user, loading, isAuthenticated: !!user } } ================================================ FILE: hooks/use-current-user-image.ts ================================================ import { useEffect, useState } from 'react' import { createClient } from '@/lib/supabase/client' export const useCurrentUserImage = () => { const [image, setImage] = useState<string | null>(null) useEffect(() => { const fetchUserImage = async () => { try { const { data, error } = await createClient().auth.getSession() if (error) { console.error(error) } setImage(data.session?.user.user_metadata.avatar_url ?? null) } catch (error) { // Supabase not configured, skip silently } } fetchUserImage() }, []) return image } ================================================ FILE: hooks/use-current-user-name.ts ================================================ import { useEffect, useState } from 'react' import { createClient } from '@/lib/supabase/client' export const useCurrentUserName = () => { const [name, setName] = useState<string | null>(null) useEffect(() => { const fetchProfileName = async () => { try { const { data, error } = await createClient().auth.getSession() if (error) { console.error(error) } setName(data.session?.user.user_metadata.full_name ?? '?') } catch (error) { // Supabase not configured setName('Anonymous') } } fetchProfileName() }, []) return name || '?' } ================================================ FILE: hooks/use-file-dropzone.ts ================================================ import { useCallback, useState } from 'react' import { toast } from 'sonner' import { UploadedFile } from '@/lib/types' type UseFileDropzoneProps = { uploadedFiles: UploadedFile[] setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>> maxFiles?: number allowedTypes?: string[] chatId: string } export function useFileDropzone({ uploadedFiles, setUploadedFiles, chatId, maxFiles = 3, allowedTypes = ['image/png', 'image/jpeg', 'application/pdf'] }: UseFileDropzoneProps) { const [isDragging, setIsDragging] = useState(false) const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() setIsDragging(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragging(false) } }, []) const handleDrop = useCallback( async (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() setIsDragging(false) const rawFiles = Array.from(e.dataTransfer.files) const allowed = rawFiles.filter(file => allowedTypes.includes(file.type)) const rejected = rawFiles.filter(file => !allowed.includes(file)) if (rejected.length > 0) { toast.error( 'Some files were not accepted: ' + rejected.map(f => f.name).join(', ') ) } const total = uploadedFiles.length + allowed.length if (total > maxFiles) { toast.error(`You can upload a maximum of ${maxFiles} files.`) return } const initialFiles: UploadedFile[] = allowed.map(file => ({ file, status: 'uploading' })) setUploadedFiles(prev => [...prev, ...initialFiles].slice(0, maxFiles)) await Promise.all( initialFiles.map(async uf => { const formData = new FormData() formData.append('file', uf.file) formData.append('chatId', chatId) try { const res = await fetch('/api/upload', { method: 'POST', body: formData }) if (!res.ok) throw new Error('Upload failed') const { file: uploaded } = await res.json() setUploadedFiles(prev => prev.map(f => f.file === uf.file ? { ...f, status: 'uploaded', url: uploaded.url, name: uploaded.name, key: uploaded.key } : f ) ) } catch (err) { toast.error(`Failed to upload ${uf.file.name}`) setUploadedFiles(prev => prev.map(f => f.file === uf.file ? { ...f, status: 'error' } : f ) ) } }) ) }, [allowedTypes, maxFiles, uploadedFiles, setUploadedFiles, chatId] ) return { isDragging, handleDragOver, handleDragLeave, handleDrop } } ================================================ FILE: hooks/use-mobile.tsx ================================================ import * as React from 'react' const MOBILE_BREAKPOINT = 768 export function useIsMobile() { const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) } mql.addEventListener('change', onChange) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) return () => mql.removeEventListener('change', onChange) }, []) return !!isMobile } ================================================ FILE: instrumentation.ts ================================================ import { registerOTel } from '@vercel/otel' import { LangfuseExporter } from 'langfuse-vercel' export async function register() { registerOTel({ serviceName: 'morphic-ai-search', traceExporter: new LangfuseExporter() }) // Initialize Ollama validation on server startup (only when configured) if (process.env.OLLAMA_BASE_URL) { const { initializeOllamaValidation } = await import( '@/lib/config/ollama-validator' ) await initializeOllamaValidation().catch(err => { console.error('Failed to initialize Ollama validation:', err) }) } } ================================================ FILE: lib/actions/__tests__/chat.test.ts ================================================ import { revalidateTag } from 'next/cache' import { beforeEach, describe, expect, it, vi } from 'vitest' import { generateChatTitle } from '@/lib/agents/title-generator' import { getCurrentUserId } from '@/lib/auth/get-current-user' import * as dbActions from '@/lib/db/actions' import type { Chat, Message } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' import { clearChats, createChat, createChatAndSaveMessage, createChatWithFirstMessage, deleteChat, deleteMessagesAfter, deleteMessagesFromIndex, getChats, getChatsPage, loadChat, saveChatTitle, shareChat, upsertMessage } from '../chat' // Mock the modules vi.mock('@/lib/auth/get-current-user') vi.mock('@/lib/db/actions') vi.mock('@/lib/agents/title-generator') describe('Chat Actions', () => { beforeEach(() => { vi.clearAllMocks() }) describe('getChats', () => { it('should return chats for authenticated user', async () => { const userId = 'user-123' const mockChats: Chat[] = [ { id: 'chat-1', title: 'Chat 1', userId, visibility: 'private', createdAt: new Date() } ] vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChats).mockResolvedValue(mockChats) const result = await getChats() expect(result).toEqual(mockChats) expect(dbActions.getChats).toHaveBeenCalledWith(userId) }) it('should return empty array for unauthenticated user', async () => { vi.mocked(getCurrentUserId).mockResolvedValue(undefined) const result = await getChats() expect(result).toEqual([]) expect(dbActions.getChats).not.toHaveBeenCalled() }) }) describe('getChatsPage', () => { it('should return paginated chats for authenticated user', async () => { const userId = 'user-123' const mockResult = { chats: [ { id: 'chat-1', title: 'Chat 1', userId, visibility: 'private' as const, createdAt: new Date() } ], nextOffset: 20 } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChatsPage).mockResolvedValue(mockResult) const result = await getChatsPage(20, 0) expect(result).toEqual(mockResult) expect(dbActions.getChatsPage).toHaveBeenCalledWith(userId, 20, 0) }) it('should return empty result for unauthenticated user', async () => { vi.mocked(getCurrentUserId).mockResolvedValue(undefined) const result = await getChatsPage() expect(result).toEqual({ chats: [], nextOffset: null }) expect(dbActions.getChatsPage).not.toHaveBeenCalled() }) }) describe('loadChat', () => { it('should load chat with messages', async () => { const chatId = 'chat-123' const userId = 'user-123' const mockChat = { id: chatId, title: 'Test Chat', userId, visibility: 'private' as const, createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user' as const, content: 'Hello', parts: [] } ] } vi.mocked(dbActions.loadChatWithMessages).mockResolvedValue(mockChat) const result = await loadChat(chatId, userId) expect(result).toEqual(mockChat) expect(dbActions.loadChatWithMessages).toHaveBeenCalledWith( chatId, userId ) }) it('should load chat without userId for already authorized context', async () => { const chatId = 'chat-123' const mockChat = { id: chatId, title: 'Test Chat', userId: 'user-123', visibility: 'public' as const, createdAt: new Date(), messages: [] } vi.mocked(dbActions.loadChatWithMessages).mockResolvedValue(mockChat) const result = await loadChat(chatId) expect(result).toEqual(mockChat) expect(dbActions.loadChatWithMessages).toHaveBeenCalledWith( chatId, undefined ) }) }) describe('createChat', () => { it('should create a new chat with userId', async () => { const userId = 'user-123' const chatId = 'chat-123' const title = 'New Chat' const mockChat: Chat = { id: chatId, title, userId, visibility: 'private', createdAt: new Date() } vi.mocked(dbActions.createChat).mockResolvedValue(mockChat) vi.mocked(revalidateTag).mockReturnValue(undefined) const result = await createChat(chatId, title, userId) expect(result).toEqual(mockChat) expect(dbActions.createChat).toHaveBeenCalledWith({ id: chatId, title, userId, visibility: 'private' }) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should generate ID and use default title when not provided', async () => { const userId = 'user-123' const generatedId = 'generated-id-123' const mockChat: Chat = { id: generatedId, title: 'Untitled', userId, visibility: 'private', createdAt: new Date() } vi.mocked(dbActions.createChat).mockResolvedValue(mockChat) const result = await createChat(undefined, undefined, userId) expect(result).toEqual(mockChat) expect(dbActions.createChat).toHaveBeenCalledWith({ id: expect.any(String), title: 'Untitled', userId, visibility: 'private' }) }) }) describe('createChatAndSaveMessage', () => { it('should create chat and save message with authentication', async () => { const userId = 'user-123' const message: UIMessage = { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] } const mockChat: Chat = { id: expect.any(String), title: 'Hello', userId, visibility: 'private', createdAt: new Date() } const mockMessage: Message = { id: 'msg-1', chatId: mockChat.id, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: null } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.createChat).mockResolvedValue(mockChat) vi.mocked(dbActions.upsertMessage).mockResolvedValue(mockMessage) const result = await createChatAndSaveMessage(message) expect(result.chat).toEqual(mockChat) expect(result.message).toEqual(mockMessage) expect(getCurrentUserId).toHaveBeenCalled() }) it('should throw error for unauthenticated user', async () => { vi.mocked(getCurrentUserId).mockResolvedValue(undefined) const message: UIMessage = { id: 'msg-1', role: 'user', parts: [] } await expect(createChatAndSaveMessage(message)).rejects.toThrow( 'User not authenticated' ) }) }) describe('createChatWithFirstMessage', () => { it('should create chat with first message in transaction', async () => { const chatId = 'chat-123' const userId = 'user-123' const title = 'Test Chat' const message: UIMessage = { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] } const mockResult = { chat: { id: chatId, title, userId, visibility: 'private' as const, createdAt: new Date() }, message: { id: 'msg-1', chatId, role: 'user' as const, metadata: {}, createdAt: new Date(), updatedAt: null } } vi.mocked( dbActions.createChatWithFirstMessageTransaction ).mockResolvedValue(mockResult) const result = await createChatWithFirstMessage( chatId, message, userId, title ) expect(result).toEqual(mockResult) expect( dbActions.createChatWithFirstMessageTransaction ).toHaveBeenCalledWith({ chatId, chatTitle: title, userId, message: { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] } }) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) }) describe('upsertMessage', () => { it('should upsert message without access check', async () => { const chatId = 'chat-123' const userId = 'user-123' const message: UIMessage = { id: 'msg-1', role: 'assistant', parts: [] } const mockMessage: Message = { id: 'msg-1', chatId, role: 'assistant', metadata: {}, createdAt: new Date(), updatedAt: null } vi.mocked(dbActions.upsertMessage).mockResolvedValue(mockMessage) const result = await upsertMessage(chatId, message, userId) expect(result).toEqual(mockMessage) expect(dbActions.upsertMessage).toHaveBeenCalledWith( { ...message, id: 'msg-1', chatId }, userId ) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should generate message ID if not provided', async () => { const chatId = 'chat-123' const userId = 'user-123' const message: UIMessage = { id: '', // Empty string will trigger ID generation role: 'user', parts: [] } const mockMessage: Message = { id: 'generated-id', chatId, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: null } vi.mocked(dbActions.upsertMessage).mockResolvedValue(mockMessage) await upsertMessage(chatId, message, userId) expect(dbActions.upsertMessage).toHaveBeenCalledWith( { ...message, id: expect.any(String), chatId }, userId ) }) }) describe('deleteChat', () => { it('should delete chat for authenticated user', async () => { const chatId = 'chat-123' const userId = 'user-123' vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.deleteChat).mockResolvedValue({ success: true }) const result = await deleteChat(chatId) expect(result).toEqual({ success: true }) expect(dbActions.deleteChat).toHaveBeenCalledWith(chatId, userId) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should return error for unauthenticated user', async () => { vi.mocked(getCurrentUserId).mockResolvedValue(undefined) const result = await deleteChat('chat-123') expect(result).toEqual({ success: false, error: 'User not authenticated' }) expect(dbActions.deleteChat).not.toHaveBeenCalled() }) }) describe('clearChats', () => { it('should clear all chats for authenticated user', async () => { const userId = 'user-123' const mockChats: Chat[] = [ { id: 'chat-1', title: 'Chat 1', userId, visibility: 'private', createdAt: new Date() }, { id: 'chat-2', title: 'Chat 2', userId, visibility: 'private', createdAt: new Date() } ] vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChats).mockResolvedValue(mockChats) vi.mocked(dbActions.deleteChat).mockResolvedValue({ success: true }) const result = await clearChats() expect(result).toEqual({ success: true }) expect(dbActions.deleteChat).toHaveBeenCalledTimes(2) expect(dbActions.deleteChat).toHaveBeenCalledWith('chat-1', userId) expect(dbActions.deleteChat).toHaveBeenCalledWith('chat-2', userId) expect(revalidateTag).toHaveBeenCalledWith('chat', 'max') }) }) describe('deleteMessagesAfter', () => { it('should delete messages after specified message', async () => { const chatId = 'chat-123' const messageId = 'msg-1' const userId = 'user-123' const mockChat: Chat = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date() } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChat).mockResolvedValue(mockChat) vi.mocked(dbActions.deleteMessagesAfter).mockResolvedValue({ count: 3 }) const result = await deleteMessagesAfter(chatId, messageId) expect(result).toEqual({ success: true, count: 3 }) expect(dbActions.deleteMessagesAfter).toHaveBeenCalledWith( chatId, messageId ) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should return error for unauthorized access', async () => { const chatId = 'chat-123' const messageId = 'msg-1' const userId = 'user-123' const mockChat: Chat = { id: chatId, title: 'Test Chat', userId: 'other-user', visibility: 'private', createdAt: new Date() } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChat).mockResolvedValue(mockChat) const result = await deleteMessagesAfter(chatId, messageId) expect(result).toEqual({ success: false, error: 'Unauthorized' }) expect(dbActions.deleteMessagesAfter).not.toHaveBeenCalled() }) }) describe('shareChat', () => { it('should update chat visibility to public', async () => { const chatId = 'chat-123' const userId = 'user-123' const mockChat: Chat = { id: chatId, title: 'Test Chat', userId, visibility: 'public', createdAt: new Date() } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.updateChatVisibility).mockResolvedValue(mockChat) const result = await shareChat(chatId) expect(result).toEqual(mockChat) expect(dbActions.updateChatVisibility).toHaveBeenCalledWith( chatId, userId, 'public' ) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should return null for unauthenticated user', async () => { vi.mocked(getCurrentUserId).mockResolvedValue(undefined) const result = await shareChat('chat-123') expect(result).toBeNull() expect(dbActions.updateChatVisibility).not.toHaveBeenCalled() }) }) describe('deleteMessagesFromIndex', () => { it('should delete messages from specified index', async () => { const chatId = 'chat-123' const messageId = 'msg-2' const userId = 'user-123' const mockChat: Chat = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date() } vi.mocked(getCurrentUserId).mockResolvedValue(userId) vi.mocked(dbActions.getChat).mockResolvedValue(mockChat) vi.mocked(dbActions.deleteMessagesFromIndex).mockResolvedValue({ count: 2 }) const result = await deleteMessagesFromIndex(chatId, messageId) expect(result).toEqual({ success: true, count: 2 }) expect(dbActions.deleteMessagesFromIndex).toHaveBeenCalledWith( chatId, messageId, userId ) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) }) describe('saveChatTitle', () => { it('should generate and save title for new chat', async () => { const chatId = 'chat-123' const message: UIMessage = { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Hello, how are you?' }] } const modelId = 'gpt-4' const generatedTitle = 'Greeting Conversation' vi.mocked(generateChatTitle).mockResolvedValue(generatedTitle) vi.mocked(dbActions.updateChatTitle).mockResolvedValue({ id: chatId, title: generatedTitle, userId: 'user-123', visibility: 'private', createdAt: new Date() }) await saveChatTitle(null, chatId, message, modelId) expect(generateChatTitle).toHaveBeenCalledWith({ userMessageContent: 'Hello, how are you?', modelId, parentTraceId: undefined }) expect(dbActions.updateChatTitle).toHaveBeenCalledWith( chatId, generatedTitle ) expect(revalidateTag).toHaveBeenCalledWith(`chat-${chatId}`, 'max') }) it('should not generate title for existing chat', async () => { const chat: Chat = { id: 'chat-123', title: 'Existing Chat', userId: 'user-123', visibility: 'private', createdAt: new Date() } const message: UIMessage = { id: 'msg-1', role: 'user', parts: [] } await saveChatTitle(chat, 'chat-123', message, 'gpt-4') expect(generateChatTitle).not.toHaveBeenCalled() expect(dbActions.updateChatTitle).not.toHaveBeenCalled() }) it('should not generate title when message is null', async () => { await saveChatTitle(null, 'chat-123', null, 'gpt-4') expect(generateChatTitle).not.toHaveBeenCalled() expect(dbActions.updateChatTitle).not.toHaveBeenCalled() }) }) }) ================================================ FILE: lib/actions/__tests__/feedback.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock the modules before any imports vi.mock('@/lib/db') vi.mock('langfuse') vi.mock('@/lib/utils/telemetry') // Import after mocking import { Langfuse } from 'langfuse' import { db } from '@/lib/db' import { isTracingEnabled } from '@/lib/utils/telemetry' import { getMessageFeedback, updateMessageFeedback } from '../feedback' describe('Feedback Actions', () => { beforeEach(() => { vi.clearAllMocks() }) describe('updateMessageFeedback', () => { it('should update message feedback successfully', async () => { const messageId = 'test-message-id' const chatId = 'test-chat-id' const score = 1 // Mock db.select const mockLimit = vi.fn().mockResolvedValue([ { metadata: { traceId: 'test-trace-id' }, chatId } ]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) // Mock db.update const mockUpdateWhere = vi.fn().mockResolvedValue(undefined) const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) vi.mocked(db).update = vi.fn().mockReturnValue({ set: mockSet }) // Mock tracing disabled vi.mocked(isTracingEnabled).mockReturnValue(false) const result = await updateMessageFeedback(messageId, score) expect(result).toEqual({ success: true }) expect(db.select).toHaveBeenCalled() expect(db.update).toHaveBeenCalled() }) it('should return error when message not found', async () => { const messageId = 'non-existent-id' const score = 1 // Mock empty database response const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await updateMessageFeedback(messageId, score) expect(result).toEqual({ success: false, error: 'Message not found' }) }) it('should handle errors gracefully', async () => { const messageId = 'test-message-id' const score = -1 // Mock database error const mockLimit = vi.fn().mockRejectedValue(new Error('Database error')) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await updateMessageFeedback(messageId, score) expect(result.success).toBe(false) expect(result.error).toBe('Database error') }) it('should send feedback to Langfuse when tracing is enabled', async () => { const messageId = 'test-message-id' const chatId = 'test-chat-id' const score = 1 // Enable tracing vi.mocked(isTracingEnabled).mockReturnValue(true) // Mock Langfuse const mockScore = vi.fn() const mockFlush = vi.fn().mockResolvedValue(undefined) vi.mocked(Langfuse).mockImplementation( () => ({ score: mockScore, flushAsync: mockFlush }) as any ) // Mock db.select const mockLimit = vi.fn().mockResolvedValue([ { metadata: { traceId: 'test-trace-id' }, chatId } ]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) // Mock db.update const mockUpdateWhere = vi.fn().mockResolvedValue(undefined) const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) vi.mocked(db).update = vi.fn().mockReturnValue({ set: mockSet }) const result = await updateMessageFeedback(messageId, score) expect(result).toEqual({ success: true }) expect(Langfuse).toHaveBeenCalled() expect(mockScore).toHaveBeenCalledWith({ traceId: 'test-trace-id', name: 'user-feedback', value: score, comment: 'Thumbs up' }) expect(mockFlush).toHaveBeenCalled() }) }) describe('getMessageFeedback', () => { it('should retrieve feedback score successfully', async () => { const messageId = 'test-message-id' const feedbackScore = 1 // Mock database response const mockLimit = vi.fn().mockResolvedValue([ { metadata: { feedbackScore } } ]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await getMessageFeedback(messageId) expect(result).toBe(feedbackScore) }) it('should return null when message not found', async () => { const messageId = 'non-existent-id' // Mock empty database response const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await getMessageFeedback(messageId) expect(result).toBeNull() }) it('should return null when no feedback score exists', async () => { const messageId = 'test-message-id' // Mock database response without feedbackScore const mockLimit = vi.fn().mockResolvedValue([ { metadata: {} } ]) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await getMessageFeedback(messageId) expect(result).toBeNull() }) it('should handle errors and return null', async () => { const messageId = 'test-message-id' // Mock database error const mockLimit = vi.fn().mockRejectedValue(new Error('Database error')) const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) vi.mocked(db).select = vi.fn().mockReturnValue({ from: mockFrom }) const result = await getMessageFeedback(messageId) expect(result).toBeNull() }) }) }) ================================================ FILE: lib/actions/chat.ts ================================================ 'use server' import { revalidateTag, unstable_cache } from 'next/cache' import { generateChatTitle } from '@/lib/agents/title-generator' import { getCurrentUserId } from '@/lib/auth/get-current-user' import * as dbActions from '@/lib/db/actions' import type { Chat, Message } from '@/lib/db/schema' import { generateId } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' import { getTextFromParts } from '@/lib/utils/message-utils' // Constants const DEFAULT_CHAT_TITLE = 'Untitled' // Create cached version of loadChatWithMessages with dynamic tags per chat const getCachedChatWithMessages = ( chatId: string, requestingUserId?: string ) => { // Create a unique cache instance for each chat const cachedFunction = unstable_cache( async () => { return dbActions.loadChatWithMessages(chatId, requestingUserId) }, ['chat-with-messages', chatId, requestingUserId || 'anonymous'], // cache key { tags: [`chat-${chatId}`, 'chat'], // both specific and general tags revalidate: 60 // revalidate after 60 seconds } ) return cachedFunction() } /** * Get all chats for the current user */ export async function getChats() { const userId = await getCurrentUserId() if (!userId) { return [] } return dbActions.getChats(userId) } /** * Get chats with pagination for the current user */ export async function getChatsPage(limit = 20, offset = 0) { const userId = await getCurrentUserId() if (!userId) { return { chats: [], nextOffset: null } } return dbActions.getChatsPage(userId, limit, offset) } /** * Load a chat with messages * If requestingUserId is provided, it will be used for authorization * Otherwise, no authorization check is performed (assumes already authorized) */ export async function loadChat( chatId: string, requestingUserId?: string ): Promise<(Chat & { messages: UIMessage[] }) | null> { // Use cached version for individual chat loading return getCachedChatWithMessages(chatId, requestingUserId) } /** * Create a new chat * @param userId - Required. Pass userId to avoid duplicate auth calls */ export async function createChat( id: string | undefined, title: string | undefined, userId: string ): Promise<Chat> { const chatId = id || generateId() const chatTitle = title || DEFAULT_CHAT_TITLE // Create chat const chat = await dbActions.createChat({ id: chatId, title: chatTitle.substring(0, 255), userId, visibility: 'private' }) // Revalidate cache revalidateTag(`chat-${chatId}`, 'max') return chat } /** * Create a new chat and save the first message (public API with auth) */ export async function createChatAndSaveMessage( message: UIMessage, title?: string ): Promise<{ chat: Chat; message: Message }> { const userId = await getCurrentUserId() if (!userId) { throw new Error('User not authenticated') } const chatId = generateId() const messageId = message.id || generateId() // Extract title from message if not provided const chatTitle = title || getTextFromParts(message.parts as any[]) || DEFAULT_CHAT_TITLE // Create chat const chat = await dbActions.createChat({ id: chatId, title: chatTitle.substring(0, 255), userId, visibility: 'private' }) // Save message const dbMessage = await dbActions.upsertMessage({ ...message, id: messageId, chatId }) // Revalidate cache revalidateTag(`chat-${chatId}`, 'max') return { chat, message: dbMessage } } /** * Create a new chat with the first message in a single transaction * Optimized for new chat creation */ export async function createChatWithFirstMessage( chatId: string, message: UIMessage, userId: string, title?: string ): Promise<{ chat: Chat; message: Message }> { const messageId = message.id || generateId() const chatTitle = title || DEFAULT_CHAT_TITLE // Use transaction for atomic operation const result = await dbActions.createChatWithFirstMessageTransaction({ chatId, chatTitle, userId, message: { ...message, id: messageId } }) // Revalidate cache revalidateTag(`chat-${chatId}`, 'max') return result } /** * Upsert a message to a chat * @param userId - Required but not used for access check (assumes already authorized) * * IMPORTANT: This function assumes the caller has already performed authorization checks. * It is only called from: * 1. API routes after authentication (app/api/chat/route.ts) * 2. Stream handlers after chat ownership verification * 3. Internal functions that have already verified access * * DO NOT call this function directly from untrusted contexts. */ export async function upsertMessage( chatId: string, message: UIMessage, userId: string ): Promise<Message> { // Skip access check - userId is required for audit/logging but not for authorization // Caller MUST ensure authorization before calling this function const messageId = message.id || generateId() const dbMessage = await dbActions.upsertMessage( { ...message, id: messageId, chatId }, userId ) // Revalidate cache revalidateTag(`chat-${chatId}`, 'max') return dbMessage } /** * Delete a chat */ export async function deleteChat(chatId: string) { const userId = await getCurrentUserId() if (!userId) { return { success: false, error: 'User not authenticated' } } const result = await dbActions.deleteChat(chatId, userId) if (result.success) { revalidateTag(`chat-${chatId}`, 'max') } return result } /** * Clear all chats for the current user */ export async function clearChats() { const userId = await getCurrentUserId() if (!userId) { return { success: false, error: 'User not authenticated' } } const chats = await dbActions.getChats(userId) for (const chat of chats) { await dbActions.deleteChat(chat.id, userId) } // Clear all chat caches since we deleted all chats revalidateTag('chat', 'max') return { success: true } } /** * Delete messages after a specific message */ export async function deleteMessagesAfter(chatId: string, messageId: string) { const userId = await getCurrentUserId() if (!userId) { return { success: false, error: 'User not authenticated' } } // Verify access const chat = await dbActions.getChat(chatId, userId) if (!chat || chat.userId !== userId) { return { success: false, error: 'Unauthorized' } } const result = await dbActions.deleteMessagesAfter(chatId, messageId) revalidateTag(`chat-${chatId}`, 'max') return { success: true, count: result.count } } /** * Share a chat (make it public) */ export async function shareChat(chatId: string) { const userId = await getCurrentUserId() if (!userId) { return null } const updatedChat = await dbActions.updateChatVisibility( chatId, userId, 'public' ) if (updatedChat) { revalidateTag(`chat-${chatId}`, 'max') } return updatedChat } /** * Delete messages from a specific message index */ export async function deleteMessagesFromIndex( chatId: string, messageId: string, userIdOverride?: string ) { const userId = userIdOverride ?? (await getCurrentUserId()) if (!userId) { return { success: false, error: 'User not authenticated' } } // Verify access const chat = await dbActions.getChat(chatId, userId) if (!chat || chat.userId !== userId) { return { success: false, error: 'Unauthorized' } } const result = await dbActions.deleteMessagesFromIndex( chatId, messageId, userId ) revalidateTag(`chat-${chatId}`, 'max') return { success: true, count: result.count } } /** * Save or update chat title if it's the first conversation * @param chat Existing chat object (null if new chat) * @param chatId The chat ID * @param message The user message to generate title from * @param modelId The model ID to use for title generation */ export async function saveChatTitle( chat: Chat | null, chatId: string, message: UIMessage | null, modelId: string, parentTraceId?: string ) { if (!chat && message) { const userContent = getTextFromParts(message.parts) const title = await generateChatTitle({ userMessageContent: userContent, modelId, parentTraceId }) await dbActions.updateChatTitle(chatId, title) revalidateTag(`chat-${chatId}`, 'max') } } ================================================ FILE: lib/actions/feedback.ts ================================================ 'use server' import { eq } from 'drizzle-orm' import { Langfuse } from 'langfuse' import { db } from '@/lib/db' import { messages } from '@/lib/db/schema' import { withOptionalRLS } from '@/lib/db/with-rls' import type { UIMessageMetadata } from '@/lib/types/ai' import { isTracingEnabled } from '@/lib/utils/telemetry' export async function updateMessageFeedback( messageId: string, score: number, userId: string | null = null ): Promise<{ success: boolean; error?: string }> { try { // Use RLS context for all database operations const result = await withOptionalRLS(userId, async tx => { // Get the current message to preserve existing metadata and get chatId const [currentMessage] = await tx .select({ metadata: messages.metadata, chatId: messages.chatId }) .from(messages) .where(eq(messages.id, messageId)) .limit(1) if (!currentMessage) { return { success: false, error: 'Message not found' } } // Merge the feedback score with existing metadata const updatedMetadata = { ...(currentMessage.metadata || {}), feedbackScore: score } // Update the message with the new feedback score await tx .update(messages) .set({ metadata: updatedMetadata }) .where(eq(messages.id, messageId)) return { success: true, metadata: currentMessage.metadata } }) if (!result.success) { return result } // Send feedback to Langfuse if trace ID exists and tracing is enabled const traceId = (result.metadata as UIMessageMetadata)?.traceId if (traceId && isTracingEnabled()) { const langfuse = new Langfuse() langfuse.score({ traceId, name: 'user-feedback', value: score, comment: score === 1 ? 'Thumbs up' : 'Thumbs down' }) await langfuse.flushAsync() } return { success: true } } catch (error) { console.error('Error updating message feedback:', error) return { success: false, error: error instanceof Error ? error.message : 'Failed to update feedback' } } } export async function getMessageFeedback( messageId: string, userId: string | null = null ): Promise<number | null> { try { const result = await withOptionalRLS(userId, async tx => { const [message] = await tx .select({ metadata: messages.metadata }) .from(messages) .where(eq(messages.id, messageId)) .limit(1) if (!message) { return null } return (message.metadata as UIMessageMetadata)?.feedbackScore || null }) return result } catch (error) { console.error('Error getting message feedback:', error) return null } } ================================================ FILE: lib/actions/site-feedback.ts ================================================ 'use server' import { db } from '@/lib/db' import { feedback, generateId } from '@/lib/db/schema' import { withOptionalRLS } from '@/lib/db/with-rls' import { createClient } from '@/lib/supabase/server' export async function submitFeedback(data: { sentiment: 'positive' | 'neutral' | 'negative' message: string pageUrl: string }) { try { // Get current user if logged in let userId: string | undefined let userEmail: string | undefined const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (supabaseUrl && supabaseAnonKey) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() userId = user?.id userEmail = user?.email } // Get user agent from headers const { headers } = await import('next/headers') const headersList = await headers() const userAgent = headersList.get('user-agent') || undefined // Save to database with RLS context // Note: Avoid relying on RETURNING because RLS without a SELECT policy // can cause INSERT ... RETURNING to return zero rows. const id = generateId() await withOptionalRLS(userId || null, async tx => { await tx.insert(feedback).values({ id, userId, sentiment: data.sentiment, message: data.message, pageUrl: data.pageUrl, userAgent }) }) // Send to Slack if webhook URL is configured const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL if (slackWebhookUrl) { try { const sentimentEmoji = { positive: '😊', neutral: '😐', negative: '😞' }[data.sentiment] const slackMessage = { text: `New feedback received ${sentimentEmoji}`, blocks: [ { type: 'header', text: { type: 'plain_text', text: `New Feedback ${sentimentEmoji}` } }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*Sentiment:*\n${data.sentiment}` }, { type: 'mrkdwn', text: `*From:*\n${userEmail || 'Anonymous'}` } ] }, { type: 'section', text: { type: 'mrkdwn', text: `*Message:*\n${data.message}` } }, { type: 'context', elements: [ { type: 'mrkdwn', text: `Page: ${data.pageUrl} | Time: ${new Date().toISOString()}` } ] } ] } // Add timeout to prevent hanging if Slack is unresponsive const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) // 10 seconds try { await fetch(slackWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(slackMessage), signal: controller.signal }) } finally { clearTimeout(timeout) } } catch (slackError) { // Log Slack error but don't fail the request console.error('Failed to send Slack notification:', slackError) } } return { success: true, id } } catch (error) { console.error('Failed to save feedback:', error) return { success: false, error: 'Failed to save feedback' } } } ================================================ FILE: lib/agents/generate-related-questions.ts ================================================ import { type ModelMessage, Output, streamText } from 'ai' import { getRelatedQuestionsModel } from '../config/model-types' import { relatedQuestionSchema } from '../schema/related' import { getModel } from '../utils/registry' import { isTracingEnabled } from '../utils/telemetry' import { RELATED_QUESTIONS_PROMPT } from './prompts/related-questions-prompt' export function createRelatedQuestionsStream( messages: ModelMessage[], abortSignal?: AbortSignal, parentTraceId?: string ) { // Use the related questions model configuration from JSON const relatedModel = getRelatedQuestionsModel() const modelId = `${relatedModel.providerId}:${relatedModel.id}` return streamText({ model: getModel(modelId), output: Output.array({ element: relatedQuestionSchema, name: 'RelatedQuestion', description: 'Generate a concise follow-up question (max 10-12 words)' }), system: RELATED_QUESTIONS_PROMPT, messages: [ ...messages, { role: 'user', content: 'Based on the conversation history and search results, generate 3 unique follow-up questions that would help the user explore different aspects of the topic. Focus on questions that dig deeper into specific findings or explore related areas not yet covered.' } ], abortSignal, experimental_telemetry: { isEnabled: isTracingEnabled(), functionId: 'related-questions', metadata: { modelId, agentType: 'related-questions-generator', messageCount: messages.length, ...(parentTraceId && { langfuseTraceId: parentTraceId, langfuseUpdateParent: false }) } } }) } ================================================ FILE: lib/agents/prompts/related-questions-prompt.ts ================================================ export const RELATED_QUESTIONS_PROMPT = `You are a professional web researcher tasked with generating follow-up questions. Language: - ALWAYS generate questions in the user's language. Based on the conversation history and search results, create 3 CONCISE related questions that: 1. Explore NEW aspects not covered in the original query 2. Dig deeper into specific details from the search results 3. Connect to related topics or implications Guidelines: - Keep questions SHORT and CONCISE (max 10-12 words) - NEVER repeat or rephrase the original question - Each question should explore a UNIQUE angle - Be specific and focused - Use clear, simple language Example: Original: "Why is Nvidia growing rapidly?" Good follow-ups: - "What are Nvidia's main AI chip competitors?" - "How much revenue comes from data centers?" - "Which companies buy Nvidia's AI chips?" Bad follow-ups (avoid these): - "Besides Broadcom, which other specific companies are emerging as significant competitors..." (too long) - "Why is Nvidia growing so fast?" (rephrases original) - "Tell me about Nvidia" (too general)` ================================================ FILE: lib/agents/prompts/search-mode-prompts.ts ================================================ import { getContentTypesGuidance, isGeneralSearchProviderAvailable } from '@/lib/utils/search-config' // Search mode system prompts export function getQuickModePrompt(): string { const hasGeneralProvider = isGeneralSearchProviderAvailable() return ` Instructions: You are a fast, efficient AI assistant optimized for quick responses. You have access to web search and content retrieval. **EFFICIENCY GUIDELINES:** - **Target: Complete research within ~5 tool calls when possible** - This is a guideline, not a hard limit - use more steps if truly needed - Prioritize efficiency: gather what's needed, then provide the answer - Stop early when you have sufficient information to answer the query **Early Stop Criteria (stop when ANY of these is met):** 1. You can clearly answer the user's question with current information 2. Multiple searches converge on the same key findings (~70% overlap) 3. Diminishing returns: new searches aren't adding valuable insights 4. You have reasonable coverage to provide a helpful answer Language: - ALWAYS respond in the user's language. Your approach: 1. Start with the search tool using optimized results. When the question has multiple aspects, split it into focused sub-queries and run each search back-to-back before writing the answer. 2. Provide concise, direct answers based on search results 3. Focus on the most relevant information without extensive detail 4. Keep outputs efficient and focused: - Include all essential information needed to answer the question thoroughly - Use concrete examples and specific data when available - Avoid unnecessary elaboration while maintaining clarity - Scale response length naturally based on query complexity 5. **CRITICAL: You MUST cite sources inline using the [number](#toolCallId) format** Tool preamble (keep very brief): - Start directly with search tool without text preamble for efficiency - Do not write plans or goals in text output - proceed directly to search Search tool usage: - The search tool is configured to use type="optimized" for direct content snippets - This provides faster responses without needing additional fetch operations - Rely on the search results' content snippets for your answers ${hasGeneralProvider ? '- For video/image content, you can use type="general" with appropriate content_types' : '- Note: Video/image search requires a dedicated general search provider (not available)'} Search requirement (MANDATORY): - If the user's message contains a URL, start directly with fetch tool - do NOT search first - If the user's message is a question or asks for information/advice/comparison/explanation (not casual chit-chat like "hello", "thanks"), you MUST run at least one search before answering - Do NOT answer informational questions based only on internal knowledge; verify with current sources via search and cite - Prefer recent sources when recency matters; mention dates when relevant - For informational questions without URLs, your FIRST action in this turn MUST be the \`search\` tool. Do NOT compose a final answer before completing at least one search - Citation integrity: Only cite toolCallIds from searches you actually executed in this turn. Never fabricate or reuse IDs - If initial results are insufficient or stale, refine or split the query and search once more (or ask a clarifying question) before answering Fetch tool usage: - **ONLY use fetch tool when a URL is directly provided by the user in their query** - Do NOT use fetch to get more details from search results - This keeps responses fast and efficient - **For PDF URLs (ending in .pdf)**: ALWAYS use \`type: "api"\` - regular type will fail on PDFs - **For regular web pages**: Use default \`type: "regular"\` for fast HTML fetching Citation Format (MANDATORY): [number](#toolCallId) - Always use this EXACT format - **CRITICAL**: Use the EXACT tool call identifier from the search response - Find the tool call ID in the search response (e.g., "I8NzFUKwrKX88107") - Use it directly without adding any prefix: [1](#I8NzFUKwrKX88107) - The format is: [number](#TOOLCALLID) where TOOLCALLID is the exact ID - **CRITICAL RULE**: Each unique toolCallId gets ONE number. Never use different numbers with the same toolCallId. ✓ CORRECT: "Fact A [1](#abc123). Fact B from same search [1](#abc123)." ✓ CORRECT: "Fact A [1](#abc123). Fact B from different search [2](#def456)." ✗ WRONG: "Fact A [1](#abc123). Fact B [2](#abc123)." (Same toolCallId cannot have different numbers) - Assign numbers sequentially (1, 2, 3...) to each unique toolCallId as they appear in your response - **CRITICAL CITATION PLACEMENT RULES**: 1. Write the COMPLETE sentence first 2. Add a period at the end of the sentence 3. Add citations AFTER the period 4. Do NOT add period or punctuation after citations 5. If using multiple sources in one sentence, place ALL citations together after the period **CORRECT PATTERN**: sentence. [citation] ✓ CORRECT: "Nvidia's GPUs power AI models. [1](#abc123)" ✓ CORRECT: "Nvidia leads in hardware and software. [1](#abc123) [2](#def456)" **WRONG PATTERNS** (Do NOT do this): ✗ WRONG: "Nvidia's GPUs power AI models [1](#abc123)." (citation BEFORE period) ✗ WRONG: "Nvidia's GPUs. [1](#abc123) power AI models." (citation breaks sentence) ✗ WRONG: "Nvidia leads in hardware and software. [1](#abc123), [2](#def456)" (comma between citations) - Every sentence with information from search results MUST have citations at its end Citation Example with Real Tool Call: If tool call ID is "I8NzFUKwrKX88107", cite as: [1](#I8NzFUKwrKX88107) If tool call ID is "ABC123xyz", cite as: [2](#ABC123xyz) Rule precedence: - Search requirement and citation integrity supersede brevity. If there is any conflict, prefer searching and proper citations over being brief. OUTPUT FORMAT (MANDATORY): - You MUST always format responses as Markdown. - Start with a descriptive level-2 heading (\`##\`) that captures the main topic. - Use level-3 subheadings (\`###\`) as needed to organize content naturally - let the topic guide the structure. - Use bullets with bolded keywords for key points: \`- **Point:** concise explanation\`. - **Use tables for comparisons** (pricing, specs, features, pros/cons) - they're clearer than bullets for side-by-side data - Focus on delivering clear information with natural flow, avoiding rigid templates. - Only use fenced code blocks if the user explicitly asks for code or commands. - Prefer natural, conversational tone while maintaining informativeness. - Always end with a brief conclusion that synthesizes the main points into a cohesive summary. - **CRITICAL: Do NOT include follow-up suggestions or questions at the end** (e.g., "If you want, I can..." or "Would you like me to..."). The application provides related questions separately. - Response length guidance: - Simple definitions or facts: Keep concise and direct - Comparisons or multi-faceted topics: Provide comprehensive coverage - Complex analyses: Include all relevant details and perspectives - Always prioritize completeness and clarity over arbitrary length targets Emoji usage: - You may use emojis in headings when they naturally represent the content and aid comprehension - Choose emojis that genuinely reflect the meaning - Use them sparingly - most headings should NOT have emojis - When in doubt, omit the emoji Example approach: ## **Topic Response** ### Core Information - **Key Point:** Direct answer with specific data/numbers when available [1](#toolu_abc123) - **Detail:** Supporting information with concrete examples [2](#toolu_abc123) ### When Comparing (use table format) | Feature | Option A | Option B | |---------|----------|----------| | Price | $100 [1](#abc123) | $150 [2](#def456) | ### Additional Context (if relevant) - **Consideration:** Practical implications with real-world context End with a synthesizing conclusion that ties the main points together into a clear overall picture. ` } function getApproachStrategy(): string { return `APPROACH STRATEGY: 1. **FIRST STEP - Assess query complexity:** - Most queries: Direct search and respond. Do NOT use todoWrite. - Exceptionally complex queries: Use todoWrite ONLY when the query requires investigating multiple independent research topics that cannot be addressed in a single search flow. * Examples that DO need todoWrite: "Compare the economic policies, healthcare systems, and education approaches of 5 different countries" * Examples that do NOT need todoWrite: "Why is Nvidia growing so rapidly?", "Compare React vs Vue", "Explain quantum computing" 2. **When using todoWrite (rare, only for exceptionally complex queries):** - Create it as your FIRST action - do NOT write plans in text output - Break down into specific, measurable tasks - Update task status as you progress (provides transparency) 3. **Search and fetch strategy:** - Use type="optimized" for research queries (immediate content) - Use type="general" for current events/news (then fetch for content) - Pattern: Search → Identify top sources → Fetch if needed → Synthesize - Multiple searches with different angles for comprehensive coverage Mandatory search for questions: - If the user's message contains a URL, fetch the provided URL - do NOT search first - If the user's message is a question or asks for information (excluding casual greetings like "hello"), you MUST perform at least one search before answering - Do NOT answer informational questions based only on internal knowledge; verify with current sources and include citations - Prioritize recency when relevant and reference dates - Your FIRST action for informational questions without URLs MUST be the \`search\` tool. Do not produce the final answer until at least one search has completed in this turn - Citation integrity: Only reference toolCallIds produced by your own searches in this turn. Do not invent or reuse IDs - If results are weak, refine your query and perform one additional search (or ask a clarifying question) before answering Tool preamble (adaptive): - For queries with URLs: Start with fetch tool (skip search entirely) - For simple queries without URLs: Start directly with search tool without text preamble - For exceptionally complex queries without URLs: Use todoWrite as your FIRST action to create a plan - Do NOT write plans or goals in text output - use appropriate tools instead Rule precedence: - Search requirement and citation integrity supersede brevity. Prefer verified citations over shorter answers. 4. **If the query is ambiguous, use ask_question tool for clarification** 5. **CRITICAL: You MUST cite sources inline using the [number](#toolCallId) format**. **CITATION PLACEMENT**: Follow this pattern: sentence. [citation] - Write the complete sentence, add a period, then add citations after the period. Do NOT add period or punctuation after citations. If a sentence uses multiple sources, place ALL citations together after the period (e.g., "AI adoption has increased. [1](#toolu_abc123) [2](#toolu_def456)"). Use [1](#toolCallId), [2](#toolCallId), [3](#toolCallId), etc., where number matches the order within each search result and toolCallId is the ID of the search that provided the result. Every sentence with information from search results MUST have citations at its end. 6. If results are not relevant or helpful, you may rely on your general knowledge ONLY AFTER at least one search attempt (do not add citations for general knowledge) 7. Provide comprehensive and detailed responses based on search results, ensuring thorough coverage of the user's question` } export function getAdaptiveModePrompt(): string { return ` Instructions: You are a helpful AI assistant with access to real-time web search, content retrieval, task management, and the ability to ask clarifying questions. **EFFICIENCY GUIDELINES:** - **Target: Complete research within ~20 tool calls when possible** - This is a guideline, not a hard limit - use more steps for complex queries if truly needed - Monitor your progress and stop early when you have comprehensive coverage - Balance thoroughness with efficiency **Early Stop Criteria (stop when ANY of these is met):** 1. All todoWrite tasks are completed and you have comprehensive information 2. Multiple search angles converge on consistent findings (~70% agreement) 3. Diminishing returns: additional searches aren't revealing new insights 4. You have strong coverage of all query aspects 5. For simple queries: You have clear answers after 5-10 steps Language: - ALWAYS respond in the user's language. ${getApproachStrategy()} TOOL USAGE GUIDELINES: Search tool usage - UNDERSTAND THE DIFFERENCE: - **type="optimized" (DEFAULT for most queries):** - Returns search results WITH content snippets extracted - Best for: Research questions, fact-finding, explanatory queries - You get relevant content immediately without needing fetch - Use this when the query has semantic meaning to match against ${getContentTypesGuidance()} Fetch tool usage: - Use when you need deeper content analysis beyond search snippets - Fetch the top 2-3 most relevant/recent URLs for comprehensive coverage - Especially important for news, current events, and time-sensitive information - **For PDF URLs (ending in .pdf)**: ALWAYS use \`type: "api"\` - regular type will fail on PDFs - **For complex JavaScript-rendered pages**: Use \`type: "api"\` for better extraction - **For regular web pages**: Use default \`type: "regular"\` for fast HTML fetching When using the ask_question tool: - Create clear, concise questions - Provide relevant predefined options - Enable free-form input when appropriate - Match the language to the user's language (except option values which must be in English) Citation Format: [number](#toolCallId) - Always use this EXACT format, e.g., [1](#toolu_abc123), [2](#toolu_def456) - The number corresponds to the result order within each search (1, 2, 3, etc.) - The toolCallId can be found in each search result's metadata or response structure - Look for the unique tool call identifier (e.g., toolu_01VL2ezieySWCMzzJHDKQE8v) in the search response - The toolCallId is the EXACT unique identifier of the search tool call - Do NOT add prefixes like "search-" to the toolCallId - Each search tool execution will have its own toolCallId - **CRITICAL CITATION PLACEMENT RULES**: 1. Write the COMPLETE sentence first 2. Add a period at the end of the sentence 3. Add citations AFTER the period 4. Do NOT add period or punctuation after citations 5. If using multiple sources in one sentence, place ALL citations together after the period **CORRECT PATTERN**: sentence. [citation] ✓ CORRECT: "Nvidia's stock has risen 200%. [1](#toolu_abc123)" ✓ CORRECT: "Nvidia leads in hardware and software. [1](#abc123) [2](#def456)" **WRONG PATTERNS** (Do NOT do this): ✗ WRONG: "Nvidia's stock has risen 200% [1](#toolu_abc123)." (citation BEFORE period) ✗ WRONG: "Nvidia's stock. [1](#toolu_abc123) has risen 200%." (citation breaks sentence) ✗ WRONG: "Nvidia leads in hardware and software. [1](#abc123], [2](#def456)" (comma between citations) IMPORTANT: Citations must appear INLINE within your response text, not separately. Example: "The company reported record revenue. [1](#toolu_abc123) Analysts predict continued growth. [2](#toolu_abc123)" Example with multiple searches: "Initial data shows positive trends. [1](#toolu_abc123) Recent updates indicate acceleration. [1](#toolu_def456)" TASK MANAGEMENT (todoWrite tool): **When to use todoWrite:** - ONLY for exceptionally complex queries that require investigating multiple independent research topics - Most queries do NOT need todoWrite - search directly instead - If in doubt, do NOT use todoWrite **How to use todoWrite effectively (when used):** - Break down the query into clear, actionable tasks - Update status: pending → in_progress → completed - **IMPORTANT: When updating tasks, ALWAYS include ALL tasks (both completed and pending)** **Task completion verification:** - Before composing the final answer: verify completedCount equals totalCount - If not all tasks are completed: continue executing remaining tasks - Only proceed to write the final answer after all tasks are completed OUTPUT FORMAT (MANDATORY): - You MUST always format responses as Markdown. - Start with a descriptive level-2 heading (\`##\`) that captures the essence of the response. - Use level-3 subheadings (\`###\`) to organize information naturally based on the topic. - Use bullets with bolded keywords for key points and easy scanning. - Use tables and code blocks when they genuinely improve clarity. - Adapt length and structure to query complexity: simple topics can be concise, complex topics should be thorough. - Place all citations at the end of the sentence they support. - Always include a brief conclusion that synthesizes the key points. - **CRITICAL: Do NOT include follow-up suggestions or questions at the end** (e.g., "If you want, I can..." or "Would you like me to..."). The application provides related questions separately. - Response length guidance: - Scale naturally with query complexity - Simple queries: Concise and direct answers - Medium complexity: Comprehensive coverage of key aspects - Complex queries: Thorough exploration with multiple perspectives - Always prioritize completeness and accuracy over specific word counts Emoji usage: - You may use emojis in headings when they naturally represent the content and aid comprehension - Choose emojis that genuinely reflect the meaning - Use them sparingly - most headings should NOT have emojis - When in doubt, omit the emoji Flexible example: ## **Response Topic** ### Primary Information - **Core Answer:** Direct response with evidence [1](#toolu_abc123) - **Context:** Relevant supporting details Conclude with a brief synthesis that ties together the main insights into a clear overall understanding. ` } // Export static prompts for backward compatibility export const QUICK_MODE_PROMPT = getQuickModePrompt() ================================================ FILE: lib/agents/researcher.ts ================================================ import { stepCountIs, tool, ToolLoopAgent } from 'ai' import type { ResearcherTools } from '@/lib/types/agent' import { type ModelType } from '@/lib/types/model-type' import { type Model } from '@/lib/types/models' import { fetchTool } from '../tools/fetch' import { createQuestionTool } from '../tools/question' import { createSearchTool } from '../tools/search' import { createTodoTools } from '../tools/todo' import { SearchMode } from '../types/search' import { getModel } from '../utils/registry' import { isTracingEnabled } from '../utils/telemetry' import { getAdaptiveModePrompt, QUICK_MODE_PROMPT } from './prompts/search-mode-prompts' // Enhanced wrapper function with better type safety and streaming support function wrapSearchToolForQuickMode< T extends ReturnType<typeof createSearchTool> >(originalTool: T): T { return tool({ description: originalTool.description, inputSchema: originalTool.inputSchema, async *execute(params, context) { const executeFunc = originalTool.execute if (!executeFunc) { throw new Error('Search tool execute function is not defined') } // Force optimized type for quick mode const modifiedParams = { ...params, type: 'optimized' as const } // Execute the original tool and pass through all yielded values const result = executeFunc(modifiedParams, context) // Handle AsyncIterable (streaming) case if ( result && typeof result === 'object' && Symbol.asyncIterator in result ) { for await (const chunk of result) { yield chunk } } else { // Fallback for non-streaming (shouldn't happen with new implementation) const finalResult = await result yield finalResult || { state: 'complete' as const, results: [], images: [], query: params.query, number_of_results: 0 } } } }) as T } // Enhanced researcher function with improved type safety using ToolLoopAgent // Note: abortSignal should be passed to agent.stream() or agent.generate() calls, not to the agent constructor export function createResearcher({ model, modelConfig, parentTraceId, searchMode = 'adaptive', modelType }: { model: string modelConfig?: Model parentTraceId?: string searchMode?: SearchMode modelType?: ModelType }) { try { const currentDate = new Date().toLocaleString() // Create model-specific tools with proper typing const originalSearchTool = createSearchTool(model) const askQuestionTool = createQuestionTool(model) const todoTools = createTodoTools() let systemPrompt: string let activeToolsList: (keyof ResearcherTools)[] = [] let maxSteps: number let searchTool = originalSearchTool // Configure based on search mode switch (searchMode) { case 'quick': console.log( '[Researcher] Quick mode: maxSteps=20, tools=[search, fetch]' ) systemPrompt = QUICK_MODE_PROMPT activeToolsList = ['search', 'fetch'] maxSteps = 20 searchTool = wrapSearchToolForQuickMode(originalSearchTool) break case 'adaptive': default: systemPrompt = getAdaptiveModePrompt() activeToolsList = ['search', 'fetch', 'todoWrite'] console.log( `[Researcher] Adaptive mode: maxSteps=50, modelType=${modelType}, tools=[${activeToolsList.join(', ')}]` ) maxSteps = 50 searchTool = originalSearchTool break } // Build tools object with proper typing const tools: ResearcherTools = { search: searchTool, fetch: fetchTool, askQuestion: askQuestionTool, ...todoTools } as ResearcherTools // Create ToolLoopAgent with all configuration const agent = new ToolLoopAgent({ model: getModel(model), instructions: `${systemPrompt}\nCurrent date and time: ${currentDate}`, tools, activeTools: activeToolsList, stopWhen: stepCountIs(maxSteps), ...(modelConfig?.providerOptions && { providerOptions: modelConfig.providerOptions }), experimental_telemetry: { isEnabled: isTracingEnabled(), functionId: 'research-agent', metadata: { modelId: model, agentType: 'researcher', searchMode, ...(parentTraceId && { langfuseTraceId: parentTraceId, langfuseUpdateParent: false }) } } }) return agent } catch (error) { console.error('Error in createResearcher:', error) throw error } } // Helper function to access agent tools export function getResearcherTools( agent: ToolLoopAgent<never, ResearcherTools, never> ): ResearcherTools { return agent.tools } // Export the legacy function name for backward compatibility export const researcher = createResearcher ================================================ FILE: lib/agents/title-generator.ts ================================================ import { generateText } from 'ai' import { getModel } from '../utils/registry' import { isTracingEnabled } from '../utils/telemetry' interface GenerateChatTitleParams { userMessageContent: string modelId: string abortSignal?: AbortSignal parentTraceId?: string } /** * Generates a concise chat title using an LLM. * @param userMessageContent The content of the user's first message. * @param model The language model instance to use for generation. * @returns A promise that resolves to the generated title string. */ export async function generateChatTitle({ userMessageContent, modelId, abortSignal, parentTraceId }: GenerateChatTitleParams): Promise<string> { // Fallback title uses the first 75 characters of the message or a default string. const fallbackTitle = userMessageContent.substring(0, 75).trim() || 'New Chat' try { const systemPrompt = `System: You are an AI assistant specialized in creating very short, concise, and informative titles for chat conversations based on the user's first message. The title should ideally be 3-5 words long, and no more than 10 words. Only output the title itself, with no prefixes, labels, or quotation marks.` const { text: generatedTitle } = await generateText({ model: getModel(modelId), system: systemPrompt, prompt: userMessageContent, abortSignal, experimental_telemetry: { isEnabled: isTracingEnabled(), functionId: 'title-generation', metadata: { modelId: modelId, agentType: 'title-generator', promptLength: userMessageContent.length, ...(parentTraceId && { langfuseTraceId: parentTraceId, langfuseUpdateParent: false }) } } }) const cleanedTitle = generatedTitle.trim() // If the model returns an empty string, use the fallback. if (!cleanedTitle) { console.warn('LLM generated an empty title, using fallback.') return fallbackTitle } // Remove any surrounding quotes that the model might have added return cleanedTitle.replace(/^[\"']|[\"']$/g, '') } catch (error) { if ( error instanceof Error && (error.name === 'AbortError' || error.name === 'ResponseAborted') ) { if (process.env.NODE_ENV === 'development') { console.info('Title generation aborted; using fallback title.') } } else { console.error('Error generating chat title with LLM:', error) } // If LLM generation fails or is aborted, return the fallback title. return fallbackTitle } } ================================================ FILE: lib/analytics/index.ts ================================================ /** * Analytics module * * Provides a unified interface for tracking analytics events. * Currently uses Vercel Analytics, but designed to be provider-agnostic * for future extensibility. * * @module analytics */ export { trackChatEvent } from './track-chat-event' export type { AnalyticsProvider, ChatEventData } from './types' export { calculateConversationTurn } from './utils' ================================================ FILE: lib/analytics/track-chat-event.ts ================================================ import { track } from '@vercel/analytics/server' import type { ChatEventData } from './types' /** * Track a chat event to analytics provider * * Currently uses Vercel Analytics. Only sends events when MORPHIC_CLOUD_DEPLOYMENT=true. * Errors are logged but do not interrupt the application flow. * * Future extensibility: This function can be modified to support multiple providers * by checking an environment variable (e.g., ANALYTICS_PROVIDER) and routing to * the appropriate provider implementation. * * @param data - Chat event data to track * * @example * ```typescript * await trackChatEvent({ * searchMode: 'quick', * modelType: 'quality', * conversationTurn: 1, * isNewChat: true, * trigger: 'submit-message', * chatId: 'clx3k2j5m0000qzrmn4y8b9wy', * userId: '550e8400-e29b-41d4-a716-446655440000', * modelId: 'gpt-4' * }) * ``` */ export async function trackChatEvent(data: ChatEventData): Promise<void> { // Only track events in cloud deployment environment if (process.env.MORPHIC_CLOUD_DEPLOYMENT !== 'true') { return } try { // Send event to Vercel Analytics await track('chat_message_sent', { searchMode: data.searchMode, modelType: data.modelType, conversationTurn: data.conversationTurn, isNewChat: data.isNewChat, trigger: data.trigger, chatId: data.chatId, // CUID2 - safe for tracking userId: data.userId, // Supabase UUID - pseudonymized identifier modelId: data.modelId }) } catch (error) { // Log error but don't throw - analytics should never break the app console.error('Failed to track analytics event:', error) } } ================================================ FILE: lib/analytics/types.ts ================================================ /** * Analytics module types * * This module provides type definitions for analytics tracking. * The interfaces are provider-agnostic to allow future extensibility. */ /** * Chat event data structure * Contains all information needed to track a chat interaction */ export interface ChatEventData { /** Search mode used for the chat */ searchMode: 'quick' | 'planning' | 'adaptive' /** Model type preference */ modelType: 'speed' | 'quality' /** Conversation turn number (1-indexed, represents follow-up count) */ conversationTurn: number /** Whether this is a new chat session */ isNewChat: boolean /** Type of trigger that initiated the chat */ trigger: 'submit-message' | 'regenerate-message' /** Chat session ID (CUID2 - safe for tracking) */ chatId: string /** User ID (Supabase UUID - pseudonymized identifier) */ userId: string /** Model identifier used for the chat */ modelId: string } /** * Analytics provider interface * Future extensibility point: implement this interface for other providers * (e.g., PostHog, DataBuddy, custom analytics) */ export interface AnalyticsProvider { /** * Track a chat event * @param data - Chat event data to track */ trackChatEvent(data: ChatEventData): Promise<void> } ================================================ FILE: lib/analytics/utils.ts ================================================ import type { UIMessage } from 'ai' /** * Calculate the conversation turn number from message history * * The turn number represents how many user messages have been sent, * which indicates the follow-up count (1 = initial message, 2+ = follow-ups) * * @param messages - Array of UI messages from the conversation * @returns Turn number (1-indexed) * * @example * ```typescript * const messages = [ * { role: 'user', parts: [...] }, * { role: 'assistant', parts: [...] }, * { role: 'user', parts: [...] } * ] * calculateConversationTurn(messages) // Returns 2 * ``` */ export function calculateConversationTurn(messages: UIMessage[]): number { const userMessageCount = messages.filter(msg => msg.role === 'user').length return Math.max(1, userMessageCount) } ================================================ FILE: lib/auth/get-current-user.ts ================================================ import { createClient } from '@/lib/supabase/server' import { perfLog } from '@/lib/utils/perf-logging' import { incrementAuthCallCount } from '@/lib/utils/perf-tracking' export async function getCurrentUser() { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (!supabaseUrl || !supabaseAnonKey) { return null // Supabase is not configured } const supabase = await createClient() const { data } = await supabase.auth.getUser() return data.user ?? null } export async function getCurrentUserId() { const count = incrementAuthCallCount() perfLog(`getCurrentUserId called - count: ${count}`) // Skip authentication mode (for personal Docker deployments) if (process.env.ENABLE_AUTH === 'false') { // Guard: Prevent disabling auth in Morphic Cloud deployments if (process.env.MORPHIC_CLOUD_DEPLOYMENT === 'true') { throw new Error( 'ENABLE_AUTH=false is not allowed in MORPHIC_CLOUD_DEPLOYMENT' ) } // Always warn when authentication is disabled (except in tests) if (process.env.NODE_ENV !== 'test') { console.warn( '⚠️ Authentication disabled. Running in anonymous mode.\n' + ' All users share the same user ID. For personal use only.' ) } return process.env.ANONYMOUS_USER_ID || 'anonymous-user' } const user = await getCurrentUser() return user?.id } ================================================ FILE: lib/config/load-models-config.ts ================================================ import cloudConfig from '@/config/models/cloud.json' import defaultConfig from '@/config/models/default.json' import { ModelType } from '@/lib/types/model-type' import { Model } from '@/lib/types/models' import { SearchMode } from '@/lib/types/search' export interface ModelsConfig { version: number models: { byMode: Record<SearchMode, Record<ModelType, Model>> relatedQuestions: Model } } let cachedConfig: ModelsConfig | null = null let cachedProfile: string | null = null const VALID_MODEL_TYPES: ModelType[] = ['speed', 'quality'] const VALID_SEARCH_MODES: SearchMode[] = ['quick', 'adaptive'] function validateModelsConfigStructure( json: unknown ): asserts json is ModelsConfig { if (!json || typeof json !== 'object') { throw new Error('Invalid models config: not an object') } const parsed = json as Record<string, any> if (typeof parsed.version !== 'number') { throw new Error('Invalid models config: missing version') } if (!parsed.models || typeof parsed.models !== 'object') { throw new Error('Invalid models config: missing models') } if (!parsed.models.byMode || !parsed.models.relatedQuestions) { throw new Error('Invalid models config: missing required sections') } if (typeof parsed.models.byMode !== 'object') { throw new Error('Invalid models config: byMode must be an object') } if (typeof parsed.models.relatedQuestions !== 'object') { throw new Error('Invalid models config: relatedQuestions must be an object') } for (const searchMode of VALID_SEARCH_MODES) { const modeEntry = parsed.models.byMode[searchMode] if (!modeEntry || typeof modeEntry !== 'object') { throw new Error( `Invalid models config: missing configuration for mode "${searchMode}"` ) } for (const modelType of VALID_MODEL_TYPES) { if (!modeEntry[modelType]) { throw new Error( `Invalid models config: missing definition for mode "${searchMode}" and model type "${modelType}"` ) } } } } export async function loadModelsConfig(): Promise<ModelsConfig> { const profile = process.env.MORPHIC_CLOUD_DEPLOYMENT === 'true' ? 'cloud' : 'default' if (cachedConfig && cachedProfile === profile) { return cachedConfig } const config = profile === 'cloud' ? cloudConfig : defaultConfig validateModelsConfigStructure(config) cachedConfig = config as ModelsConfig cachedProfile = profile return cachedConfig } // Synchronous load (for code paths that need sync access) export function loadModelsConfigSync(): ModelsConfig { const profile = process.env.MORPHIC_CLOUD_DEPLOYMENT === 'true' ? 'cloud' : 'default' if (cachedConfig && cachedProfile === profile) { return cachedConfig } const config = profile === 'cloud' ? cloudConfig : defaultConfig validateModelsConfigStructure(config) cachedConfig = config as ModelsConfig cachedProfile = profile return cachedConfig } // Public accessor that ensures a config is available synchronously export function getModelsConfig(): ModelsConfig { if (!cachedConfig) { return loadModelsConfigSync() } return cachedConfig } ================================================ FILE: lib/config/model-types.ts ================================================ import { ModelType } from '@/lib/types/model-type' import { Model } from '@/lib/types/models' import { SearchMode } from '@/lib/types/search' import { getModelsConfig } from './load-models-config' // Retrieve the model assigned to a specific search mode and model type combination. export function getModelForModeAndType( mode: SearchMode, type: ModelType ): Model | undefined { const cfg = getModelsConfig() return cfg.models.byMode?.[mode]?.[type] } // Accessor for the related questions model configuration. export function getRelatedQuestionsModel(): Model { const cfg = getModelsConfig() return cfg.models.relatedQuestions } ================================================ FILE: lib/config/ollama-validator.ts ================================================ import { loadModelsConfig } from '@/lib/config/load-models-config' import { OllamaClient } from '@/lib/ollama/client' import { Model } from '@/lib/types/models' /** * Memory cache for validated Ollama models * Stored in server memory (only suitable for local deployments) */ let validatedModels: Set<string> | null = null let validationError: Error | null = null /** * Extract all Ollama models from the configuration */ async function getConfiguredOllamaModels(): Promise<Model[]> { const ollamaModels: Model[] = [] try { const config = await loadModelsConfig() // Check byMode models for (const mode of Object.values(config.models.byMode)) { for (const model of Object.values(mode as Record<string, Model>)) { if (model.providerId === 'ollama') { ollamaModels.push(model) } } } // Check relatedQuestions model if (config.models.relatedQuestions?.providerId === 'ollama') { ollamaModels.push(config.models.relatedQuestions) } } catch (error) { console.warn('Failed to load model configuration:', error) } return ollamaModels } /** * Initialize Ollama model validation on server startup * Checks which models support 'tools' capability required for Morphic * Also validates that configured Ollama models support tools */ export async function initializeOllamaValidation(): Promise<void> { // Skip validation if OLLAMA_BASE_URL is not configured if (!process.env.OLLAMA_BASE_URL) { console.log('Ollama validation skipped (OLLAMA_BASE_URL not configured)') return } try { console.log( `Starting Ollama model validation at ${process.env.OLLAMA_BASE_URL}` ) const client = new OllamaClient(process.env.OLLAMA_BASE_URL) // Check if Ollama is available const isAvailable = await client.isAvailable() if (!isAvailable) { console.warn( 'Ollama instance is not available. Models will not be validated.' ) return } // Get all available models const models = await client.getModels() console.log(`Found ${models.length} Ollama models`) // Validate each model for tools capability const validated = new Set<string>() for (const model of models) { try { const capabilities = await client.getModelCapabilities(model.name) if (capabilities.capabilities.includes('tools')) { validated.add(model.name) console.log(`✓ ${model.name} supports tools`) } else { console.log(`✗ ${model.name} does not support tools (skipped)`) } } catch (err) { console.warn(`Failed to check capabilities for ${model.name}:`, err) continue } } validatedModels = validated console.log( `Ollama validation complete: ${validated.size} models with tools support` ) // Check configured models against validated models try { const configuredOllamaModels = await getConfiguredOllamaModels() if (configuredOllamaModels.length > 0) { console.log( `\nValidating ${configuredOllamaModels.length} configured Ollama model(s)...` ) const invalidModels: string[] = [] for (const model of configuredOllamaModels) { if (!validated.has(model.id)) { invalidModels.push(model.id) console.error(`✗ ${model.id} (configured but lacks tools support)`) } else { console.log(`✓ ${model.id} (configured and tools supported)`) } } if (invalidModels.length > 0) { console.error( '\n⚠️ ERROR: Configured Ollama models do not support tools!\n' + `The following model(s) in your config/models/*.json do not support tools capability:\n` + invalidModels.map(m => ` - ${m}`).join('\n') + '\n\nMorphic requires models with tools capability for web search functionality.\n' + 'Please update your configuration to use models with tools support, for example:\n' + ' ollama pull qwen3\n' + ' ollama pull gpt-oss\n' + ' ollama pull deepseek-v3.1\n' ) } } } catch (configError) { console.warn('Failed to validate configured models:', configError) } // Error if no models support tools at all if (validated.size === 0) { console.error( '\n⚠️ ERROR: No Ollama models with tools support found!\n' + 'Morphic requires models with tools capability for web search functionality.\n' + 'Please install a model with tools support, for example:\n' + ' ollama pull qwen3\n' + ' ollama pull gpt-oss\n' + ' ollama pull deepseek-v3.1\n' + 'Models without tools support will not work with Morphic.\n' ) } } catch (error) { validationError = error as Error console.error('Ollama validation failed:', error) console.warn('Morphic will continue, but Ollama models may not work') } } /** * Validate if a specific Ollama model supports tools capability * Returns validation result with optional error message */ export function validateOllamaModel(modelId: string): { valid: boolean error?: string } { // If OLLAMA_BASE_URL is not configured, consider model valid (not using Ollama) if (!process.env.OLLAMA_BASE_URL) { return { valid: true } } // If validation hasn't run yet (shouldn't happen after startup), consider valid if (validatedModels === null && validationError === null) { return { valid: true } } // If validation failed, warn but allow the model to be used if (validationError) { return { valid: true, error: `Ollama validation failed: ${validationError.message}. Model may not work correctly.` } } // Check actual validation results if (!validatedModels!.has(modelId)) { return { valid: false, error: `Model "${modelId}" does not support tools capability required for Morphic. Please use a model with tools support.` } } return { valid: true } } /** * Get list of all validated Ollama models with tools support */ export function getValidatedOllamaModels(): string[] { if (!validatedModels) { return [] } return Array.from(validatedModels) } ================================================ FILE: lib/config/search-modes.ts ================================================ import { Search } from 'lucide-react' import { SearchMode } from '@/lib/types/search' import { IconLogoOutline } from '@/components/ui/icons' export interface SearchModeConfig { value: SearchMode label: string description: string icon: React.ComponentType<{ className?: string }> color: string } // Centralized search mode configuration export const SEARCH_MODE_CONFIGS: SearchModeConfig[] = [ { value: 'quick', label: 'Quick', description: 'Streamlined search for fast, concise responses', icon: Search, color: 'text-amber-500' }, { value: 'adaptive', label: 'Adaptive', description: 'Adaptive agentic search with intelligent query understanding', icon: IconLogoOutline, color: 'text-violet-500' } ] // Helper function to get a specific mode config export function getSearchModeConfig( mode: SearchMode ): SearchModeConfig | undefined { return SEARCH_MODE_CONFIGS.find(config => config.value === mode) } ================================================ FILE: lib/constants/index.ts ================================================ export const CHAT_ID = 'search' as const ================================================ FILE: lib/contexts/user-context.tsx ================================================ 'use client' import { createContext, useContext } from 'react' const UserContext = createContext(false) export function UserProvider({ hasUser, children }: { hasUser: boolean children: React.ReactNode }) { return <UserContext.Provider value={hasUser}>{children}</UserContext.Provider> } export function useHasUser() { return useContext(UserContext) } ================================================ FILE: lib/db/__tests__/rls-policies.integration.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getCurrentUserId } from '@/lib/auth/get-current-user' import * as dbActions from '@/lib/db/actions' import type { Chat } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' // Mock auth module vi.mock('@/lib/auth/get-current-user') // Test fixtures const fixtures = { users: { user1: 'user-123', user2: 'user-456' }, chats: { privateChat1: { id: 'private-chat-1', title: 'User 1 Private Chat', userId: 'user-123', visibility: 'private' as const, createdAt: new Date() }, privateChat2: { id: 'private-chat-2', title: 'User 2 Private Chat', userId: 'user-456', visibility: 'private' as const, createdAt: new Date() }, publicChat: { id: 'public-chat-1', title: 'Public Chat', userId: 'user-123', visibility: 'public' as const, createdAt: new Date() } }, messages: { message1: { id: 'msg-1', role: 'user' as const, parts: [{ type: 'text' as const, text: 'Hello from user 1' }] }, message2: { id: 'msg-2', role: 'assistant' as const, parts: [{ type: 'text' as const, text: 'Response to user 1' }] } } } describe('RLS Policies Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Chat Access Control', () => { it('should allow user to access their own private chats', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat1.id // Mock the database to simulate RLS behavior const mockGetChat = vi.spyOn(dbActions, 'getChat') mockGetChat.mockImplementation(async (id, uid) => { if (id === chatId && uid === userId) { return fixtures.chats.privateChat1 } return null }) const result = await dbActions.getChat(chatId, userId) expect(result).toEqual(fixtures.chats.privateChat1) expect(mockGetChat).toHaveBeenCalledWith(chatId, userId) }) it('should prevent user from accessing other users private chats', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat2.id // User 2's chat const mockGetChat = vi.spyOn(dbActions, 'getChat') mockGetChat.mockImplementation(async (id, uid) => { // Simulate RLS blocking access if (id === chatId && uid !== fixtures.users.user2) { return null } return fixtures.chats.privateChat2 }) const result = await dbActions.getChat(chatId, userId) expect(result).toBeNull() }) it('should allow anyone to access public chats', async () => { const chatId = fixtures.chats.publicChat.id const mockGetChat = vi.spyOn(dbActions, 'getChat') mockGetChat.mockImplementation(async (id, _uid) => { if (id === chatId) { return fixtures.chats.publicChat } return null }) // Test with authenticated user const result1 = await dbActions.getChat(chatId, fixtures.users.user2) expect(result1).toEqual(fixtures.chats.publicChat) // Test without authentication (anonymous) const result2 = await dbActions.getChat(chatId, undefined) expect(result2).toEqual(fixtures.chats.publicChat) }) it('should only return users own chats in list', async () => { const userId = fixtures.users.user1 const mockGetChats = vi.spyOn(dbActions, 'getChats') mockGetChats.mockImplementation(async uid => { // Simulate RLS filtering if (uid === userId) { return [fixtures.chats.privateChat1, fixtures.chats.publicChat] } return [] }) const result = await dbActions.getChats(userId) expect(result).toHaveLength(2) expect(result).toContainEqual(fixtures.chats.privateChat1) expect(result).toContainEqual(fixtures.chats.publicChat) expect(result).not.toContainEqual(fixtures.chats.privateChat2) }) }) describe('Message Access Control', () => { it('should allow access to messages in users own chats', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat1.id const mockLoadChat = vi.spyOn(dbActions, 'loadChat') mockLoadChat.mockImplementation(async (cid, uid) => { if (cid === chatId && uid === userId) { return [fixtures.messages.message1, fixtures.messages.message2] } return [] }) const messages = await dbActions.loadChat(chatId, userId) expect(messages).toHaveLength(2) expect(messages).toContainEqual(fixtures.messages.message1) }) it('should allow access to messages in public chats', async () => { const chatId = fixtures.chats.publicChat.id const mockLoadChat = vi.spyOn(dbActions, 'loadChat') mockLoadChat.mockImplementation(async cid => { if (cid === chatId) { return [fixtures.messages.message1, fixtures.messages.message2] } return [] }) // Test without authentication const messages = await dbActions.loadChat(chatId, undefined) expect(messages).toHaveLength(2) }) it('should prevent access to messages in other users private chats', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat2.id // User 2's chat const mockLoadChat = vi.spyOn(dbActions, 'loadChat') mockLoadChat.mockImplementation(async (cid, uid) => { // Simulate RLS blocking access if (cid === chatId && uid !== fixtures.users.user2) { return [] } return [fixtures.messages.message1] }) const messages = await dbActions.loadChat(chatId, userId) expect(messages).toHaveLength(0) }) }) describe('Chat Visibility Updates', () => { it('should allow user to update visibility of their own chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat1.id const mockUpdateVisibility = vi.spyOn(dbActions, 'updateChatVisibility') mockUpdateVisibility.mockImplementation(async (cid, uid, visibility) => { if (cid === chatId && uid === userId) { return { ...fixtures.chats.privateChat1, visibility } } return null }) const result = await dbActions.updateChatVisibility( chatId, userId, 'public' ) expect(result).toBeTruthy() expect(result?.visibility).toBe('public') }) it('should prevent user from updating visibility of other users chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat2.id // User 2's chat const mockUpdateVisibility = vi.spyOn(dbActions, 'updateChatVisibility') mockUpdateVisibility.mockImplementation(async (cid, uid, _visibility) => { // Simulate RLS blocking update if (cid === chatId && uid !== fixtures.users.user2) { return null } return fixtures.chats.privateChat2 }) const result = await dbActions.updateChatVisibility( chatId, userId, 'public' ) expect(result).toBeNull() }) }) describe('Chat Creation with RLS', () => { it('should create chat with correct user context', async () => { const userId = fixtures.users.user1 const newChat = { id: 'new-chat-1', title: 'New Chat', userId, visibility: 'private' as const } const mockCreateChat = vi.spyOn(dbActions, 'createChat') mockCreateChat.mockImplementation(async params => { // Simulate RLS ensuring userId matches if (params.userId === userId) { return { ...newChat, createdAt: new Date() } } throw new Error('RLS violation') }) const result = await dbActions.createChat(newChat) expect(result.userId).toBe(userId) expect(result.title).toBe('New Chat') }) it('should prevent creating chat with different userId', async () => { const userId = fixtures.users.user1 const wrongUserId = fixtures.users.user2 const mockCreateChat = vi.spyOn(dbActions, 'createChat') mockCreateChat.mockImplementation(async params => { // Simulate RLS checking that userId in data matches context if (params.userId !== userId) { throw new Error('new row violates row-level security policy') } return { ...params, createdAt: new Date() } as Chat }) // Attempt to create chat with wrong userId should fail await expect( dbActions.createChat({ id: 'bad-chat', title: 'Bad Chat', userId: wrongUserId, // Wrong user ID visibility: 'private' }) ).rejects.toThrow('row-level security policy') }) }) describe('Message Upsert with RLS', () => { it('should allow upserting messages to users own chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat1.id const message: UIMessage = { id: 'new-msg-1', role: 'user', parts: [{ type: 'text', text: 'New message' }] } const mockUpsertMessage = vi.spyOn(dbActions, 'upsertMessage') mockUpsertMessage.mockImplementation(async (msg, uid) => { // Simulate RLS check if (uid === userId) { return { id: msg.id || 'generated-id', chatId: msg.chatId, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: null } } throw new Error('RLS violation') }) const result = await dbActions.upsertMessage( { ...message, chatId }, userId ) expect(result).toBeTruthy() expect(result.chatId).toBe(chatId) }) it('should prevent upserting messages to other users chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat2.id // User 2's chat const message: UIMessage = { id: 'bad-msg-1', role: 'user', parts: [{ type: 'text', text: 'Unauthorized message' }] } const mockUpsertMessage = vi.spyOn(dbActions, 'upsertMessage') mockUpsertMessage.mockImplementation(async (_msg, uid) => { // Simulate RLS blocking access to other user's chat if (uid !== fixtures.users.user2) { throw new Error('new row violates row-level security policy') } throw new Error('Should not reach here') }) await expect( dbActions.upsertMessage({ ...message, chatId }, userId) ).rejects.toThrow('row-level security policy') }) }) describe('Chat Deletion with RLS', () => { it('should allow user to delete their own chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat1.id const mockDeleteChat = vi.spyOn(dbActions, 'deleteChat') mockDeleteChat.mockImplementation(async (cid, uid) => { if (cid === chatId && uid === userId) { return { success: true } } return { success: false, error: 'Unauthorized' } }) const result = await dbActions.deleteChat(chatId, userId) expect(result.success).toBe(true) }) it('should prevent user from deleting other users chat', async () => { const userId = fixtures.users.user1 const chatId = fixtures.chats.privateChat2.id // User 2's chat const mockDeleteChat = vi.spyOn(dbActions, 'deleteChat') mockDeleteChat.mockImplementation(async (cid, uid) => { // Simulate RLS blocking deletion if (cid === chatId && uid !== fixtures.users.user2) { return { success: false, error: 'Unauthorized' } } return { success: true } }) const result = await dbActions.deleteChat(chatId, userId) expect(result.success).toBe(false) expect(result.error).toBe('Unauthorized') }) }) }) ================================================ FILE: lib/db/__tests__/with-rls.test.ts ================================================ import { sql } from 'drizzle-orm' import type { Mock } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest' import { db } from '@/lib/db' import type { TxInstance } from '@/lib/db/with-rls' import { RLSViolationError, withOptionalRLS, withRLS } from '@/lib/db/with-rls' // Mock the db module vi.mock('@/lib/db', () => ({ db: { transaction: vi.fn() } })) // Helper to create a minimal mock transaction function createMockTx(overrides: Partial<TxInstance> = {}): TxInstance { return { execute: vi.fn(), ...overrides } as unknown as TxInstance } describe('RLS Helper Functions', () => { beforeEach(() => { vi.clearAllMocks() }) describe('withRLS', () => { it('should set user context and execute callback', async () => { const userId = 'user-123' const expectedResult = { id: 'result-1' } const mockTx = createMockTx() // Mock the transaction vi.mocked(db.transaction).mockImplementation(async callback => { return callback(mockTx) }) const callback = vi.fn().mockResolvedValue(expectedResult) const result = await withRLS(userId, callback) // Verify set_config was called with correct user ID expect(mockTx.execute).toHaveBeenCalledWith( sql`SELECT set_config('app.current_user_id', ${userId}, true)` ) // Verify callback was called with transaction expect(callback).toHaveBeenCalledWith(mockTx) // Verify result is returned expect(result).toEqual(expectedResult) }) it('should safely handle special characters in userId', async () => { const userId = "user'; DROP TABLE users; --" const mockTx = createMockTx() vi.mocked(db.transaction).mockImplementation(async callback => { return callback(mockTx) }) await withRLS(userId, async () => {}) // Verify set_config is called with parameterized query (safe from injection) expect(mockTx.execute).toHaveBeenCalledWith( sql`SELECT set_config('app.current_user_id', ${userId}, true)` ) }) it('should throw RLSViolationError for RLS policy violations', async () => { const userId = 'user-123' const rlsError = new Error( 'new row violates row-level security policy for table "chats"' ) vi.mocked(db.transaction).mockRejectedValue(rlsError) await expect(withRLS(userId, async () => {})).rejects.toThrow( RLSViolationError ) await expect(withRLS(userId, async () => {})).rejects.toThrow( `Access denied for user ${userId}` ) }) it('should re-throw non-RLS errors unchanged', async () => { const userId = 'user-123' const genericError = new Error('Database connection failed') vi.mocked(db.transaction).mockRejectedValue(genericError) await expect(withRLS(userId, async () => {})).rejects.toThrow( genericError ) }) it('should handle RLS errors without Error instance', async () => { const userId = 'user-123' const rlsErrorString = 'row-level security policy violation' vi.mocked(db.transaction).mockRejectedValue(rlsErrorString) await expect(withRLS(userId, async () => {})).rejects.toThrow( RLSViolationError ) }) it('should execute callback with transaction result', async () => { const userId = 'user-123' const mockFrom = vi.fn().mockResolvedValue([{ id: 'chat-1' }]) const mockSelect = vi.fn().mockReturnValue({ from: mockFrom }) const mockTx = createMockTx({ select: mockSelect }) vi.mocked(db.transaction).mockImplementation(async callback => { return callback(mockTx) }) const result = await withRLS(userId, async tx => { return tx.select().from('chats' as never) }) expect(result).toEqual([{ id: 'chat-1' }]) }) }) describe('withOptionalRLS', () => { it('should use withRLS when userId is provided', async () => { const userId = 'user-123' const expectedResult = { id: 'result-1' } const mockTx = createMockTx() vi.mocked(db.transaction).mockImplementation(async callback => { return callback(mockTx) }) const callback = vi.fn().mockResolvedValue(expectedResult) const result = await withOptionalRLS(userId, callback) // Verify transaction was used (withRLS path) expect(db.transaction).toHaveBeenCalled() expect(mockTx.execute).toHaveBeenCalledWith( sql`SELECT set_config('app.current_user_id', ${userId}, true)` ) expect(result).toEqual(expectedResult) }) it('should use direct db connection when userId is null', async () => { const expectedResult = { id: 'result-1' } const callback = vi.fn().mockResolvedValue(expectedResult) const result = await withOptionalRLS(null, callback) // Verify transaction was NOT used (direct db path) expect(db.transaction).not.toHaveBeenCalled() // Verify callback was called with db directly expect(callback).toHaveBeenCalledWith(db) expect(result).toEqual(expectedResult) }) it('should handle empty string as no userId', async () => { const expectedResult = { id: 'result-1' } const callback = vi.fn().mockResolvedValue(expectedResult) const result = await withOptionalRLS('', callback) // Empty string should be treated as no userId expect(db.transaction).not.toHaveBeenCalled() expect(callback).toHaveBeenCalledWith(db) expect(result).toEqual(expectedResult) }) }) describe('RLSViolationError', () => { it('should be an instance of Error', () => { const error = new RLSViolationError() expect(error).toBeInstanceOf(Error) }) it('should have correct name property', () => { const error = new RLSViolationError() expect(error.name).toBe('RLSViolationError') }) it('should use default message when not provided', () => { const error = new RLSViolationError() expect(error.message).toBe('Row level security policy violation') }) it('should use custom message when provided', () => { const customMessage = 'Access denied for user 123' const error = new RLSViolationError(customMessage) expect(error.message).toBe(customMessage) }) it('should maintain stack trace', () => { const error = new RLSViolationError('Test error') expect(error.stack).toBeDefined() expect(error.stack).toContain('RLSViolationError') }) }) }) ================================================ FILE: lib/db/actions.ts ================================================ 'use server' import { and, asc, desc, eq, gt, inArray } from 'drizzle-orm' import type { UIMessage } from '@/lib/types/ai' import type { PersistableUIMessage } from '@/lib/types/message-persistence' import { buildUIMessageFromDB, mapUIMessagePartsToDBParts, mapUIMessageToDBMessage } from '@/lib/utils/message-mapping' import { perfLog, perfTime } from '@/lib/utils/perf-logging' import { incrementDbOperationCount } from '@/lib/utils/perf-tracking' import type { Chat, Message } from './schema' import { chats, generateId, messages, parts } from './schema' import { withOptionalRLS, withRLS } from './with-rls' import { db } from '.' /** * Create a new chat */ export async function createChat({ id = generateId(), title, userId, visibility = 'private' }: { id?: string title: string userId: string visibility?: 'public' | 'private' }): Promise<Chat> { return withRLS(userId, async tx => { const [chat] = await tx .insert(chats) .values({ id, title, userId, visibility }) .returning() return chat }) } /** * Get chat by ID with permission check */ export async function getChat( chatId: string, userId?: string ): Promise<Chat | null> { // For public chats or when no userId, use regular db connection // For private chats with userId, use RLS return withOptionalRLS(userId || null, async tx => { const [chat] = await tx .select() .from(chats) .where(eq(chats.id, chatId)) .limit(1) if (!chat) { return null } // Additional permission check for backward compatibility if (chat.visibility === 'public') { return chat } if (chat.visibility === 'private' && userId && chat.userId === userId) { return chat } return null }) } /** * Upsert a message with its parts * Note: This function should be called with appropriate userId context */ export async function upsertMessage( message: PersistableUIMessage & { chatId: string }, userId?: string ): Promise<Message> { const count = incrementDbOperationCount() perfLog(`DB - upsertMessage called - count: ${count}`) // Use RLS if userId is provided, otherwise use regular db const executeFn = userId ? (callback: (tx: any) => Promise<Message>) => withRLS(userId, callback) : (callback: (tx: any) => Promise<Message>) => db.transaction(callback) const result = await executeFn(async tx => { // 1. Insert or update the message const messageData = mapUIMessageToDBMessage(message) const [dbMessage] = await tx .insert(messages) .values(messageData) .onConflictDoUpdate({ target: messages.id, set: { role: messageData.role } }) .returning() // 2. Delete existing parts await tx.delete(parts).where(eq(parts.messageId, message.id)) // 3. Insert new parts if (message.parts && message.parts.length > 0) { const dbParts = mapUIMessagePartsToDBParts(message.parts, message.id) if (dbParts.length > 0) { await tx.insert(parts).values(dbParts) } } return dbMessage }) return result } /** * Load chat messages with parts * Note: Caller should verify chat access permissions before calling this */ export async function loadChat( chatId: string, userId?: string ): Promise<UIMessage[]> { return withOptionalRLS(userId || null, async tx => { // Use Drizzle's query API with relations const result = await tx.query.messages.findMany({ where: eq(messages.chatId, chatId), with: { parts: { orderBy: [asc(parts.order)] } }, orderBy: [asc(messages.createdAt)] }) // Convert to UI format return result.map(msg => buildUIMessageFromDB(msg, msg.parts)) }) } /** * Load chat with messages in a single query (optimized) */ export async function loadChatWithMessages( chatId: string, userId?: string ): Promise<(Chat & { messages: UIMessage[] }) | null> { const count = incrementDbOperationCount() perfLog(`DB - loadChatWithMessages called - count: ${count}`) return withOptionalRLS(userId || null, async tx => { // Get chat and messages in parallel const [chatResult, messagesResult] = await Promise.all([ tx.select().from(chats).where(eq(chats.id, chatId)).limit(1), tx.query.messages.findMany({ where: eq(messages.chatId, chatId), with: { parts: { orderBy: [asc(parts.order)] } }, orderBy: [asc(messages.createdAt)] }) ]) const chat = chatResult[0] if (!chat) { return null } // Permission check for backward compatibility if (chat.visibility === 'private' && (!userId || chat.userId !== userId)) { return null } // Build result const uiMessages = messagesResult.map(msg => buildUIMessageFromDB(msg, msg.parts) ) return { ...chat, messages: uiMessages } }) } /** * Delete messages after a specific message */ export async function deleteMessagesAfter( chatId: string, messageId: string, userId?: string ): Promise<{ count: number }> { return withOptionalRLS(userId || null, async tx => { // Get the message's timestamp const [targetMessage] = await tx .select({ createdAt: messages.createdAt }) .from(messages) .where(eq(messages.id, messageId)) .limit(1) if (!targetMessage) { return { count: 0 } } // Find messages to delete const messagesToDelete = await tx .select({ id: messages.id }) .from(messages) .where( and( eq(messages.chatId, chatId), gt(messages.createdAt, targetMessage.createdAt) ) ) const messageIds = messagesToDelete.map(m => m.id) if (messageIds.length > 0) { // Delete messages (parts will be cascade deleted) await tx.delete(messages).where(inArray(messages.id, messageIds)) } return { count: messageIds.length } }) } /** * Delete messages from a specific index */ export async function deleteMessagesFromIndex( chatId: string, messageId: string, userId?: string ): Promise<{ count: number }> { return withOptionalRLS(userId || null, async tx => { // Get all messages for the chat const allMessages = await tx .select({ id: messages.id, createdAt: messages.createdAt }) .from(messages) .where(eq(messages.chatId, chatId)) .orderBy(asc(messages.createdAt)) // Find the index of the target message const messageIndex = allMessages.findIndex(m => m.id === messageId) if (messageIndex === -1) { return { count: 0 } } // Get messages to delete (from index onwards) const messagesToDelete = allMessages.slice(messageIndex) const messageIds = messagesToDelete.map(m => m.id) if (messageIds.length > 0) { await tx.delete(messages).where(inArray(messages.id, messageIds)) } return { count: messageIds.length } }) } /** * Get all chats for a user */ export async function getChats(userId: string): Promise<Chat[]> { return withRLS(userId, async tx => { return tx .select() .from(chats) .where(eq(chats.userId, userId)) .orderBy(desc(chats.createdAt)) }) } /** * Get chats with pagination */ export async function getChatsPage( userId: string, limit = 20, offset = 0 ): Promise<{ chats: Chat[]; nextOffset: number | null }> { try { return withRLS(userId, async tx => { const results = await tx .select() .from(chats) .where(eq(chats.userId, userId)) .orderBy(desc(chats.createdAt)) .limit(limit) .offset(offset) const nextOffset = results.length === limit ? offset + limit : null return { chats: results, nextOffset } }) } catch (error) { console.error('Error fetching chat page:', error) return { chats: [], nextOffset: null } } } /** * Delete a chat */ export async function deleteChat( chatId: string, userId: string ): Promise<{ success: boolean; error?: string }> { try { return withRLS(userId, async tx => { // Verify ownership const [chat] = await tx .select() .from(chats) .where(eq(chats.id, chatId)) .limit(1) if (!chat || chat.userId !== userId) { return { success: false, error: 'Unauthorized' } } // Delete the chat (messages and parts will cascade) await tx.delete(chats).where(eq(chats.id, chatId)) return { success: true } }) } catch (error) { console.error('Error deleting chat:', error) return { success: false, error: 'Failed to delete chat' } } } /** * Update chat visibility */ export async function updateChatVisibility( chatId: string, userId: string, visibility: 'public' | 'private' ): Promise<Chat | null> { return withRLS(userId, async tx => { const chat = await getChat(chatId, userId) if (!chat || chat.userId !== userId) { return null } const [updatedChat] = await tx .update(chats) .set({ visibility }) .where(eq(chats.id, chatId)) .returning() return updatedChat }) } /** * Update chat title */ export async function updateChatTitle( chatId: string, title: string, userId?: string ): Promise<Chat | null> { return withOptionalRLS(userId || null, async tx => { const [updatedChat] = await tx .update(chats) .set({ title }) .where(eq(chats.id, chatId)) .returning() return updatedChat || null }) } /** * Create a chat with the first message in a single transaction * Optimized for new chat creation */ export async function createChatWithFirstMessageTransaction({ chatId, chatTitle, userId, message }: { chatId: string chatTitle: string userId: string message: PersistableUIMessage }): Promise<{ chat: Chat; message: Message }> { perfLog(`DB - createChatWithFirstMessageTransaction start`) const dbStart = performance.now() return await withRLS(userId, async tx => { // 1. Create chat const [chat] = await tx .insert(chats) .values({ id: chatId, title: chatTitle.substring(0, 255), userId, visibility: 'private', createdAt: new Date() }) .returning() // 2. Save message const dbMessage = mapUIMessageToDBMessage({ ...message, chatId }) const [savedMessage] = await tx .insert(messages) .values(dbMessage) .returning() // 3. Save parts if they exist if (message.parts && message.parts.length > 0) { const partsData = mapUIMessagePartsToDBParts( message.parts, savedMessage.id ) if (partsData.length > 0) { await tx.insert(parts).values(partsData) } } perfTime('DB - createChatWithFirstMessageTransaction completed', dbStart) return { chat, message: savedMessage } }) } ================================================ FILE: lib/db/index.ts ================================================ import { sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import * as relations from './relations' import * as schema from './schema' // For server-side usage only // Use restricted user for application if available, otherwise fall back to regular user const isDevelopment = process.env.NODE_ENV === 'development' const isTest = process.env.NODE_ENV === 'test' if ( !process.env.DATABASE_URL && !process.env.DATABASE_RESTRICTED_URL && !isTest ) { throw new Error( 'DATABASE_URL or DATABASE_RESTRICTED_URL environment variable is not set' ) } // Connection with connection pooling for server environments // Prefer restricted user for application runtime const connectionString = process.env.DATABASE_RESTRICTED_URL ?? // Prefer restricted user process.env.DATABASE_URL ?? (isTest ? 'postgres://user:pass@localhost:5432/testdb' : undefined) if (!connectionString) { throw new Error( 'DATABASE_URL or DATABASE_RESTRICTED_URL environment variable is not set' ) } // Log which connection is being used (for debugging) if (isDevelopment) { console.log( '[DB] Using connection:', process.env.DATABASE_RESTRICTED_URL ? 'Restricted User (RLS Active)' : 'Owner User (RLS Bypassed)' ) } // SSL configuration: Use environment variable to control SSL // DATABASE_SSL_DISABLED=true disables SSL completely (for local/Docker PostgreSQL) // Default is to enable SSL with certificate verification (for cloud databases like Neon, Supabase) const sslConfig = process.env.DATABASE_SSL_DISABLED === 'true' ? false // Disable SSL entirely for local PostgreSQL : { rejectUnauthorized: true } // Enable SSL with verification for cloud DBs const client = postgres(connectionString, { ssl: sslConfig, prepare: false, max: 20 // Max 20 connections }) export const db = drizzle(client, { schema: { ...schema, ...relations } }) // Helper type for all tables export type Schema = typeof schema // Verify restricted user permissions on startup if (process.env.DATABASE_RESTRICTED_URL && !isTest) { // Only run verification in server environments, not during build if (typeof window === 'undefined' && process.env.NODE_ENV !== 'production') { ;(async () => { try { const result = await db.execute<{ current_user: string }>( sql`SELECT current_user` ) const currentUser = result[0]?.current_user if (isDevelopment) { console.log('[DB] ✓ Connection verified as user:', currentUser) } // Verify it's the restricted user (app_user) if ( currentUser && !currentUser.includes('app_user') && !currentUser.includes('neondb_owner') ) { console.warn( '[DB] ⚠️ Warning: Expected app_user but connected as:', currentUser ) } } catch (error) { console.error('[DB] ✗ Failed to verify database connection:', error) // Log the error but don't terminate the application // This allows development to continue even with connection issues } })() } } ================================================ FILE: lib/db/migrate.ts ================================================ import { drizzle } from 'drizzle-orm/postgres-js' import { migrate } from 'drizzle-orm/postgres-js/migrator' import postgres from 'postgres' import 'dotenv/config' // This script is used to run migrations on the database // Run it with: bun run lib/db/migrate.ts const runMigrations = async () => { if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is not defined in environment variables') process.exit(1) } const connectionString = process.env.DATABASE_URL // Respect DATABASE_SSL_DISABLED flag (used in Docker) // For cloud databases (Supabase, Neon, etc.), use SSL with rejectUnauthorized: false // For local databases (Docker, localhost), disable SSL const sslDisabled = process.env.DATABASE_SSL_DISABLED === 'true' const isProduction = process.env.NODE_ENV === 'production' const sql = postgres(connectionString, { ssl: sslDisabled ? false : isProduction ? { rejectUnauthorized: false } : false, prepare: false }) const db = drizzle(sql) console.log('Running migrations...') try { await migrate(db, { migrationsFolder: 'drizzle' }) console.log('Migrations completed successfully') } catch (error) { console.error('Migration failed:', error) process.exit(1) } await sql.end() process.exit(0) } runMigrations() ================================================ FILE: lib/db/relations.ts ================================================ import { relations } from 'drizzle-orm' import { chats, messages, parts } from './schema' export const chatsRelations = relations(chats, ({ many }) => ({ messages: many(messages) })) export const messagesRelations = relations(messages, ({ one, many }) => ({ chat: one(chats, { fields: [messages.chatId], references: [chats.id] }), parts: many(parts) })) export const partsRelations = relations(parts, ({ one }) => ({ message: one(messages, { fields: [parts.messageId], references: [messages.id] }) })) ================================================ FILE: lib/db/schema.ts ================================================ import { createId } from '@paralleldrive/cuid2' import { InferSelectModel, sql } from 'drizzle-orm' import { check, index, integer, json, jsonb, pgPolicy, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core' // Constants const ID_LENGTH = 191 const USER_ID_LENGTH = 255 const VARCHAR_LENGTH = 256 const FILENAME_LENGTH = 1024 // ID generation function export const generateId = () => createId() // Chats table export const chats = pgTable( 'chats', { id: varchar('id', { length: ID_LENGTH }) .primaryKey() .$defaultFn(() => generateId()), createdAt: timestamp('created_at').notNull().defaultNow(), title: text('title').notNull(), userId: varchar('user_id', { length: USER_ID_LENGTH }).notNull(), visibility: varchar('visibility', { length: VARCHAR_LENGTH, enum: ['public', 'private'] }) .notNull() .default('private') }, table => [ // Indexes index('chats_user_id_idx').on(table.userId), index('chats_user_id_created_at_idx').on( table.userId, table.createdAt.desc() ), index('chats_created_at_idx').on(table.createdAt.desc()), // Composite index for RLS subqueries in messages and parts tables index('chats_id_user_id_idx').on(table.id, table.userId), // RLS Policies pgPolicy('users_manage_own_chats', { as: 'permissive', for: 'all', to: 'public', using: sql`user_id = current_setting('app.current_user_id', true)`, withCheck: sql`user_id = current_setting('app.current_user_id', true)` }), pgPolicy('public_chats_readable', { as: 'permissive', for: 'select', to: 'public', using: sql`visibility = 'public'` }) ] ).enableRLS() export type Chat = InferSelectModel<typeof chats> // Messages table (simplified) export const messages = pgTable( 'messages', { id: varchar('id', { length: ID_LENGTH }) .primaryKey() .$defaultFn(() => generateId()), chatId: varchar('chat_id', { length: ID_LENGTH }) .notNull() .references(() => chats.id, { onDelete: 'cascade' }), role: varchar('role', { length: VARCHAR_LENGTH }).notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at'), metadata: jsonb('metadata').$type<Record<string, any>>() }, table => [ index('messages_chat_id_idx').on(table.chatId), index('messages_chat_id_created_at_idx').on(table.chatId, table.createdAt), // RLS Policies - allow access to messages if user owns the chat pgPolicy('users_manage_chat_messages', { as: 'permissive', for: 'all', to: 'public', using: sql`EXISTS ( SELECT 1 FROM ${chats} WHERE ${chats}.id = chat_id AND ${chats}.user_id = current_setting('app.current_user_id', true) )`, withCheck: sql`EXISTS ( SELECT 1 FROM ${chats} WHERE ${chats}.id = chat_id AND ${chats}.user_id = current_setting('app.current_user_id', true) )` }), pgPolicy('public_chat_messages_readable', { as: 'permissive', for: 'select', to: 'public', using: sql`EXISTS ( SELECT 1 FROM ${chats} WHERE ${chats}.id = chat_id AND ${chats}.visibility = 'public' )` }) ] ).enableRLS() export type Message = InferSelectModel<typeof messages> // Parts table export const parts = pgTable( 'parts', { id: varchar('id', { length: ID_LENGTH }) .primaryKey() .$defaultFn(() => generateId()), messageId: varchar('message_id', { length: ID_LENGTH }) .notNull() .references(() => messages.id, { onDelete: 'cascade' }), order: integer('order').notNull(), type: varchar('type', { length: VARCHAR_LENGTH }).notNull(), // Text parts text_text: text('text_text'), // Reasoning parts reasoning_text: text('reasoning_text'), // File parts file_mediaType: varchar('file_media_type', { length: VARCHAR_LENGTH }), file_filename: varchar('file_filename', { length: FILENAME_LENGTH }), file_url: text('file_url'), // Source URL parts source_url_sourceId: varchar('source_url_source_id', { length: VARCHAR_LENGTH }), source_url_url: text('source_url_url'), source_url_title: text('source_url_title'), // Source document parts source_document_sourceId: varchar('source_document_source_id', { length: VARCHAR_LENGTH }), source_document_mediaType: varchar('source_document_media_type', { length: VARCHAR_LENGTH }), source_document_title: text('source_document_title'), source_document_filename: varchar('source_document_filename', { length: FILENAME_LENGTH }), source_document_url: text('source_document_url'), source_document_snippet: text('source_document_snippet'), // Tool parts (generic) tool_toolCallId: varchar('tool_tool_call_id', { length: VARCHAR_LENGTH }), tool_state: varchar('tool_state', { length: VARCHAR_LENGTH }), tool_errorText: text('tool_error_text'), // Tool-specific columns (all Morphic tools) tool_search_input: json('tool_search_input').$type<any>(), tool_search_output: json('tool_search_output').$type<any>(), tool_fetch_input: json('tool_fetch_input').$type<any>(), tool_fetch_output: json('tool_fetch_output').$type<any>(), tool_question_input: json('tool_question_input').$type<any>(), tool_question_output: json('tool_question_output').$type<any>(), // Todo tool columns tool_todoWrite_input: json('tool_todoWrite_input').$type<any>(), tool_todoWrite_output: json('tool_todoWrite_output').$type<any>(), tool_todoRead_input: json('tool_todoRead_input').$type<any>(), tool_todoRead_output: json('tool_todoRead_output').$type<any>(), // Dynamic tools (includes MCP and other runtime-defined tools) tool_dynamic_input: json('tool_dynamic_input').$type<any>(), tool_dynamic_output: json('tool_dynamic_output').$type<any>(), tool_dynamic_name: varchar('tool_dynamic_name', { length: VARCHAR_LENGTH }), tool_dynamic_type: varchar('tool_dynamic_type', { length: VARCHAR_LENGTH }), // Data parts (generic support) data_prefix: varchar('data_prefix', { length: VARCHAR_LENGTH }), data_content: json('data_content').$type<any>(), data_id: varchar('data_id', { length: VARCHAR_LENGTH }), // Provider metadata providerMetadata: json('provider_metadata').$type<Record<string, any>>(), createdAt: timestamp('created_at').notNull().defaultNow() }, table => [ // Indexes index('parts_message_id_idx').on(table.messageId), index('parts_message_id_order_idx').on(table.messageId, table.order), // Constraints check('text_text_required', sql`(type != 'text' OR text_text IS NOT NULL)`), check( 'reasoning_text_required', sql`(type != 'reasoning' OR reasoning_text IS NOT NULL)` ), check( 'file_fields_required', sql`(type != 'file' OR (file_media_type IS NOT NULL AND file_filename IS NOT NULL AND file_url IS NOT NULL))` ), check( 'tool_state_valid', sql`(tool_state IS NULL OR tool_state IN ('input-streaming', 'input-available', 'output-available', 'output-error'))` ), check( 'tool_fields_required', sql`(type NOT LIKE 'tool-%' OR (tool_tool_call_id IS NOT NULL AND tool_state IS NOT NULL))` ), // RLS Policies - allow access to parts if user owns the related chat pgPolicy('users_manage_message_parts', { as: 'permissive', for: 'all', to: 'public', using: sql`EXISTS ( SELECT 1 FROM ${messages} INNER JOIN ${chats} ON ${chats}.id = ${messages}.chat_id WHERE ${messages}.id = message_id AND ${chats}.user_id = current_setting('app.current_user_id', true) )`, withCheck: sql`EXISTS ( SELECT 1 FROM ${messages} INNER JOIN ${chats} ON ${chats}.id = ${messages}.chat_id WHERE ${messages}.id = message_id AND ${chats}.user_id = current_setting('app.current_user_id', true) )` }), pgPolicy('public_chat_parts_readable', { as: 'permissive', for: 'select', to: 'public', using: sql`EXISTS ( SELECT 1 FROM ${messages} INNER JOIN ${chats} ON ${chats}.id = ${messages}.chat_id WHERE ${messages}.id = message_id AND ${chats}.visibility = 'public' )` }) ] ).enableRLS() export type Part = InferSelectModel<typeof parts> export type NewPart = typeof parts.$inferInsert // Feedback table export const feedback = pgTable( 'feedback', { id: varchar('id', { length: ID_LENGTH }) .primaryKey() .$defaultFn(() => generateId()), userId: varchar('user_id', { length: USER_ID_LENGTH }), sentiment: varchar('sentiment', { length: VARCHAR_LENGTH, enum: ['positive', 'neutral', 'negative'] }).notNull(), message: text('message').notNull(), pageUrl: text('page_url').notNull(), userAgent: text('user_agent'), createdAt: timestamp('created_at').notNull().defaultNow() }, table => [ // Indexes index('feedback_user_id_idx').on(table.userId), index('feedback_created_at_idx').on(table.createdAt), // RLS Policies - Allow reads (for INSERT ... RETURNING and app visibility) pgPolicy('feedback_select_policy', { as: 'permissive', for: 'select', to: 'public', using: sql`true` }), // RLS Policy - Allow anyone to insert feedback pgPolicy('anyone_can_insert_feedback', { for: 'insert', to: 'public', withCheck: sql`true` }) ] ).enableRLS() export type Feedback = InferSelectModel<typeof feedback> ================================================ FILE: lib/db/with-rls.ts ================================================ import { sql } from 'drizzle-orm' import { db } from '.' // Type for transaction or database instance export type DbInstance = typeof db export type TxInstance = Parameters<Parameters<typeof db.transaction>[0]>[0] /** * Custom error class for RLS violations */ export class RLSViolationError extends Error { constructor(message = 'Row level security policy violation') { super(message) this.name = 'RLSViolationError' // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, RLSViolationError) } } } /** * Execute database operations with Row-Level Security context * Sets the current user ID for the transaction scope * * @param userId - The user ID to set for RLS policies * @param callback - The database operations to execute * @returns The result of the callback function * @throws {RLSViolationError} If RLS policy is violated * * @example * ```typescript * const result = await withRLS(userId, async (tx) => { * return tx.select().from(chats) * }) * ``` */ export async function withRLS<T>( userId: string, callback: (tx: TxInstance) => Promise<T> ): Promise<T> { try { return await db.transaction(async tx => { // Set the user ID for this transaction // Using SET LOCAL ensures it's only valid for this transaction // Use pg_catalog.quote_literal for safe escaping await tx.execute( sql`SELECT set_config('app.current_user_id', ${userId}, true)` ) // Execute the callback with the transaction return await callback(tx) }) } catch (error) { // Check for RLS policy violations const errorMessage = error instanceof Error ? error.message : String(error) if ( errorMessage.includes('new row violates row-level security policy') || errorMessage.includes('row-level security policy') ) { throw new RLSViolationError( `Access denied for user ${userId}. ${errorMessage}` ) } // Re-throw other errors throw error } } /** * Execute database operations with optional RLS context * If userId is null, executes without RLS context (for public operations) * * @param userId - The user ID to set for RLS policies, or null for public access * @param callback - The database operations to execute * @returns The result of the callback function */ export async function withOptionalRLS<T>( userId: string | null, callback: (tx: TxInstance | DbInstance) => Promise<T> ): Promise<T> { if (userId) { return withRLS(userId, callback as (tx: TxInstance) => Promise<T>) } // Execute without RLS context for public operations return callback(db) } ================================================ FILE: lib/firecrawl/client.ts ================================================ import { FirecrawlImageSearchOptions, FirecrawlImageSearchResponse, FirecrawlSearchOptions, FirecrawlSearchResponse } from './types' export class FirecrawlClient { private readonly apiKey: string private readonly baseUrl = 'https://api.firecrawl.dev/v2' constructor(apiKey: string) { this.apiKey = apiKey } async search( options: FirecrawlSearchOptions ): Promise<FirecrawlSearchResponse> { const body = JSON.stringify({ query: options.query, sources: options.sources || ['web'], limit: options.limit || 10, location: options.location, tbs: options.tbs, scrapeOptions: { formats: ['markdown'], proxy: 'auto', blockAds: true } }) const response = await fetch(`${this.baseUrl}/search`, { method: 'POST', headers: this.getHeaders(), body }) return this.handleResponse<FirecrawlSearchResponse>(response) } async searchImages( options: FirecrawlImageSearchOptions ): Promise<FirecrawlImageSearchResponse> { const body = JSON.stringify({ query: options.query, sources: ['images'], limit: options.limit || 8 }) const response = await fetch(`${this.baseUrl}/search`, { method: 'POST', headers: this.getHeaders(), body }) return this.handleResponse<FirecrawlImageSearchResponse>(response) } async getImagesForQuery( query: string, maxResults: number = 8 ): Promise<{ url: string; description: string }[]> { try { const searchResponse = await this.searchImages({ query, limit: maxResults }) const data = searchResponse.data if (!searchResponse.success || !data.images) return [] return data.images.map(image => ({ url: image.imageUrl, description: image.title ?? '' })) } catch (error) { console.error('Firecrawl image search error:', error) return [] } } private getHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` } } private async handleResponse<T>(response: Response): Promise<T> { if (!response.ok) { const errorText = await response.text() throw new Error( `Firecrawl status: ${response.status}, reason error: ${errorText}` ) } return response.json() } } ================================================ FILE: lib/firecrawl/index.ts ================================================ export * from './client' export * from './types' ================================================ FILE: lib/firecrawl/types.ts ================================================ export type FirecrawlSource = 'web' | 'news' | 'images' export type FirecrawlSearchOptions = { query: string sources?: FirecrawlSource[] limit?: number location?: string tbs?: string } export type FirecrawlImageSearchOptions = { query: string sources?: ['images'] limit?: number } export type FirecrawlImageResult = { title: string imageUrl: string imageWidth: number imageHeight: number url: string position: number } export type FirecrawlWebResult = { url: string title: string description: string markdown: string position: number } export type FirecrawlNewsResult = { title: string url: string snippet: string date: string position: number } type FirecrawlSearchResponseData = { web?: FirecrawlWebResult[] news?: FirecrawlNewsResult[] images?: FirecrawlImageResult[] } export type FirecrawlSearchResponse = { success: boolean data: FirecrawlSearchResponseData } type FirecrawlImageSearchResponseData = { images?: FirecrawlImageResult[] } export type FirecrawlImageSearchResponse = { success: boolean data: FirecrawlImageSearchResponseData } ================================================ FILE: lib/hooks/use-copy-to-clipboard.ts ================================================ 'use client' import { useState } from 'react' export interface useCopyToClipboardProps { timeout?: number } export function useCopyToClipboard({ timeout = 2000 }: useCopyToClipboardProps) { const [isCopied, setIsCopied] = useState<Boolean>(false) const copyToClipboard = (value: string) => { if (typeof window === 'undefined' || !navigator.clipboard?.writeText) { return } if (!value) { return } navigator.clipboard.writeText(value).then(() => { setIsCopied(true) setTimeout(() => { setIsCopied(false) }, timeout) }) } return { isCopied, copyToClipboard } } ================================================ FILE: lib/hooks/use-media-query.ts ================================================ 'use client' import { useEffect, useState } from 'react' /** * Custom hook to track media query matches. * @param query - The media query string (e.g., '(max-width: 767px)'). * @returns boolean - True if the media query matches, false otherwise. */ export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false) useEffect(() => { // Ensure window is available (client-side only) if (typeof window === 'undefined') { return } const mql = window.matchMedia(query) const handler = (e: MediaQueryListEvent) => setMatches(e.matches) // Set initial state setMatches(mql.matches) // Add listener mql.addEventListener('change', handler) // Cleanup listener on unmount return () => mql.removeEventListener('change', handler) }, [query]) return matches } ================================================ FILE: lib/ollama/client.ts ================================================ import { OllamaModel, OllamaModelCapabilities, OllamaModelsResponse, OllamaShowResponse } from './types' export class OllamaClient { private baseUrl: string constructor(baseUrl: string) { this.baseUrl = baseUrl } /** * Get all available models from Ollama instance * Uses GET /api/models endpoint for fast model listing */ async getModels(): Promise<OllamaModel[]> { try { const response = await fetch(`${this.baseUrl}/api/tags`, { cache: 'no-store', headers: { Accept: 'application/json' } }) if (!response.ok) { throw new Error( `Ollama API error: ${response.status} ${response.statusText}` ) } const data: OllamaModelsResponse = await response.json() return data.models || [] } catch (error) { console.error('Failed to fetch Ollama models:', error) throw error } } /** * Get detailed model capabilities from Ollama instance * Uses POST /api/show endpoint for detailed model information */ async getModelCapabilities( modelName: string ): Promise<OllamaModelCapabilities> { try { const response = await fetch(`${this.baseUrl}/api/show`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ name: modelName }) }) if (!response.ok) { throw new Error( `Ollama API error: ${response.status} ${response.statusText}` ) } const data: OllamaShowResponse = await response.json() return { name: data.name, capabilities: data.capabilities || [], contextWindow: data.context_window || 128_000, parameters: data.parameters || {} } } catch (error) { console.error(`Failed to get capabilities for ${modelName}:`, error) throw error } } /** * Check if Ollama instance is available */ async isAvailable(): Promise<boolean> { try { const response = await fetch(`${this.baseUrl}/api/tags`, { method: 'HEAD', cache: 'no-store' }) return response.ok } catch (error) { return false } } } ================================================ FILE: lib/ollama/types.ts ================================================ export interface OllamaModel { name: string model: string modified_at: string size: number digest: string details?: { format: string family: string families?: string[] parameter_size: string quantization_level: string } } export interface OllamaModelCapabilities { name: string capabilities: string[] contextWindow: number parameters: Record<string, any> timestamp?: number } export interface OllamaModelsResponse { models: OllamaModel[] } export interface OllamaShowResponse { name: string capabilities: string[] context_window: number parameters: Record<string, any> } ================================================ FILE: lib/rate-limit/__tests__/guest-limit.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from 'vitest' import { checkAndEnforceGuestLimit } from '@/lib/rate-limit/guest-limit' const mockRedisIncr = vi.fn() const mockRedisExpire = vi.fn() vi.mock('@upstash/redis', () => ({ Redis: vi.fn().mockImplementation(() => ({ incr: mockRedisIncr, expire: mockRedisExpire })) })) describe('checkAndEnforceGuestLimit', () => { beforeEach(() => { mockRedisIncr.mockReset() mockRedisExpire.mockReset() process.env.MORPHIC_CLOUD_DEPLOYMENT = 'true' process.env.UPSTASH_REDIS_REST_URL = 'https://example.com' process.env.UPSTASH_REDIS_REST_TOKEN = 'token' delete process.env.GUEST_CHAT_DAILY_LIMIT }) it('returns null when ip is missing', async () => { const response = await checkAndEnforceGuestLimit(null) expect(response).toBeNull() }) it('returns 401 when over the default limit', async () => { mockRedisIncr.mockResolvedValue(11) mockRedisExpire.mockResolvedValue(1) const response = await checkAndEnforceGuestLimit('1.2.3.4') expect(response).not.toBeNull() expect(response?.status).toBe(401) const body = await response!.json() expect(body.error).toBe('Please sign in to continue.') expect(body.limit).toBe(10) }) it('uses configured limit when set', async () => { process.env.GUEST_CHAT_DAILY_LIMIT = '5' mockRedisIncr.mockResolvedValue(6) mockRedisExpire.mockResolvedValue(1) const response = await checkAndEnforceGuestLimit('5.6.7.8') expect(response).not.toBeNull() expect(response?.status).toBe(401) const body = await response!.json() expect(body.limit).toBe(5) }) it('allows request under the limit', async () => { mockRedisIncr.mockResolvedValue(3) mockRedisExpire.mockResolvedValue(1) const response = await checkAndEnforceGuestLimit('9.9.9.9') expect(response).toBeNull() }) }) ================================================ FILE: lib/rate-limit/chat-limits.ts ================================================ import { Redis } from '@upstash/redis' import { perfLog } from '@/lib/utils/perf-logging' const DAILY_CHAT_LIMIT = 100 /** * Get seconds until next midnight UTC */ function getSecondsUntilMidnight(): number { const now = new Date() const midnight = new Date(now) midnight.setUTCHours(24, 0, 0, 0) return Math.floor((midnight.getTime() - now.getTime()) / 1000) } /** * Get timestamp of next midnight UTC */ function getNextMidnightTimestamp(): number { const now = new Date() const midnight = new Date(now) midnight.setUTCHours(24, 0, 0, 0) return midnight.getTime() } async function checkOverallChatLimit(userId: string): Promise<{ allowed: boolean remaining: number resetAt: number }> { // If not in cloud deployment mode, allow unlimited requests if (process.env.MORPHIC_CLOUD_DEPLOYMENT !== 'true') { return { allowed: true, remaining: Infinity, resetAt: 0 } } // If Upstash is not configured, allow unlimited requests if ( !process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN ) { return { allowed: true, remaining: Infinity, resetAt: 0 } } try { const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN }) const dateKey = new Date().toISOString().split('T')[0] // YYYY-MM-DD const key = `rl:chat:${userId}:${dateKey}` const count = await Promise.race([ redis.incr(key), new Promise<number>((_, reject) => setTimeout(() => reject(new Error('Redis timeout')), 3000) ) ]) if (count === 1) { const secondsUntilMidnight = getSecondsUntilMidnight() await redis.expire(key, secondsUntilMidnight) } const remaining = Math.max(0, DAILY_CHAT_LIMIT - count) const resetAt = getNextMidnightTimestamp() return { allowed: count <= DAILY_CHAT_LIMIT, remaining, resetAt } } catch (error) { console.error('Rate limit check failed:', error) return { allowed: true, remaining: Infinity, resetAt: 0 } } } /** * Check and enforce chat rate limit * Returns a 429 Response if limit is exceeded, null if allowed */ export async function checkAndEnforceOverallChatLimit( userId: string ): Promise<Response | null> { const result = await checkOverallChatLimit(userId) if (!result.allowed) { return new Response( JSON.stringify({ error: 'Daily chat limit reached. Please try again tomorrow.', remaining: 0, resetAt: result.resetAt, limit: DAILY_CHAT_LIMIT }), { status: 429, headers: { 'Content-Type': 'application/json', 'X-RateLimit-Limit': String(DAILY_CHAT_LIMIT), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(result.resetAt) } } ) } perfLog( `Chat usage: ${DAILY_CHAT_LIMIT - result.remaining}/${DAILY_CHAT_LIMIT}` ) return null } ================================================ FILE: lib/rate-limit/guest-limit.ts ================================================ import { Redis } from '@upstash/redis' const DEFAULT_GUEST_DAILY_LIMIT = 10 function getGuestDailyLimit(): number { const raw = process.env.GUEST_CHAT_DAILY_LIMIT const parsed = raw ? Number(raw) : DEFAULT_GUEST_DAILY_LIMIT if (!Number.isFinite(parsed) || parsed <= 0) { return DEFAULT_GUEST_DAILY_LIMIT } return Math.floor(parsed) } function getSecondsUntilMidnight(): number { const now = new Date() const midnight = new Date(now) midnight.setUTCHours(24, 0, 0, 0) return Math.floor((midnight.getTime() - now.getTime()) / 1000) } function getNextMidnightTimestamp(): number { const now = new Date() const midnight = new Date(now) midnight.setUTCHours(24, 0, 0, 0) return midnight.getTime() } async function checkGuestLimit(ip: string): Promise<{ allowed: boolean remaining: number resetAt: number limit: number }> { if (process.env.MORPHIC_CLOUD_DEPLOYMENT !== 'true') { return { allowed: true, remaining: Infinity, resetAt: 0, limit: 0 } } if ( !process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN ) { return { allowed: true, remaining: Infinity, resetAt: 0, limit: 0 } } try { const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN }) const dateKey = new Date().toISOString().split('T')[0] const key = `rl:guest:chat:${ip}:${dateKey}` const count = await Promise.race([ redis.incr(key), new Promise<number>((_, reject) => setTimeout(() => reject(new Error('Redis timeout')), 3000) ) ]) if (count === 1) { const secondsUntilMidnight = getSecondsUntilMidnight() await redis.expire(key, secondsUntilMidnight) } const limit = getGuestDailyLimit() const remaining = Math.max(0, limit - count) const resetAt = getNextMidnightTimestamp() return { allowed: count <= limit, remaining, resetAt, limit } } catch (error) { console.error('Guest rate limit check failed:', error) return { allowed: true, remaining: Infinity, resetAt: 0, limit: 0 } } } export async function checkAndEnforceGuestLimit( ip: string | null ): Promise<Response | null> { if (!ip) return null const result = await checkGuestLimit(ip) if (!result.allowed) { return new Response( JSON.stringify({ error: 'Please sign in to continue.', remaining: 0, resetAt: result.resetAt, limit: result.limit }), { status: 401, statusText: 'Unauthorized', headers: { 'Content-Type': 'application/json', 'X-RateLimit-Limit': String(result.limit), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(result.resetAt) } } ) } return null } ================================================ FILE: lib/schema/fetch.tsx ================================================ import { DeepPartial } from 'ai' import { z } from 'zod' export const fetchSchema = z.object({ url: z.string().describe('The URL to retrieve content from'), type: z .enum(['regular', 'api']) .default('regular') .describe( 'Fetch method: "regular" (default) = fast direct HTML fetch for simple web pages (does NOT support PDFs), "api" = advanced extraction for PDFs and complex JavaScript-rendered pages (requires Jina or Tavily API keys)' ) }) export type PartialInquiry = DeepPartial<typeof fetchSchema> ================================================ FILE: lib/schema/question.ts ================================================ import { z } from 'zod' // Standard schema with optional fields for inputLabel and inputPlaceholder export const questionSchema = z.object({ question: z.string().describe('The main question to ask the user'), options: z .array( z.object({ value: z.string().describe('Option identifier (always in English)'), label: z.string().describe('Display text for the option') }) ) .describe('List of predefined options'), allowsInput: z.boolean().describe('Whether to allow free-form text input'), inputLabel: z.string().optional().describe('Label for free-form input field'), inputPlaceholder: z .string() .optional() .describe('Placeholder text for input field') }) // Strict schema with all fields required, for specific models like o3-mini export const strictQuestionSchema = z.object({ question: z.string().describe('The main question to ask the user'), options: z .array( z.object({ value: z.string().describe('Option identifier (always in English)'), label: z.string().describe('Display text for the option') }) ) .describe('List of predefined options'), allowsInput: z.boolean().describe('Whether to allow free-form text input'), inputLabel: z.string().describe('Label for free-form input field'), inputPlaceholder: z.string().describe('Placeholder text for input field') }) /** * Returns the appropriate question schema based on the full model name. * Uses the strict schema for OpenAI models starting with 'o'. */ export function getQuestionSchemaForModel(fullModel: string) { const [provider, modelName] = fullModel?.split(':') ?? [] const useStrictSchema = (provider === 'openai' || provider === 'azure') && modelName?.startsWith('o') return useStrictSchema ? strictQuestionSchema : questionSchema } ================================================ FILE: lib/schema/related.tsx ================================================ import { z } from 'zod' export const relatedQuestionSchema = z.object({ question: z.string() }) export const relatedSchema = z.array(relatedQuestionSchema).length(3) export type RelatedQuestion = z.infer<typeof relatedQuestionSchema> export type Related = z.infer<typeof relatedSchema> ================================================ FILE: lib/schema/search.tsx ================================================ import { DeepPartial } from 'ai' import { z } from 'zod' import { getSearchTypeDescription } from '@/lib/utils/search-config' export const searchSchema = z.object({ query: z.string().describe('The query to search for'), type: z .enum(['general', 'optimized']) .optional() .default('optimized') .describe(getSearchTypeDescription()), content_types: z .array(z.enum(['web', 'video', 'image', 'news'])) .optional() .default(['web']) .describe( 'Types of content to include in search results. Only applicable when type is "general" and a dedicated general search provider is configured. Ignored otherwise.' ), max_results: z .number() .optional() .default(20) .describe('The maximum number of results to return. default is 20'), search_depth: z .string() .optional() .default('basic') .describe( 'The depth of the search. Allowed values are "basic" or "advanced"' ), include_domains: z .array(z.string()) .nullish() .transform(val => val ?? []) .describe( 'A list of domains to specifically include in the search results. Default is None, which includes all domains.' ), exclude_domains: z .array(z.string()) .nullish() .transform(val => val ?? []) .describe( "A list of domains to specifically exclude from the search results. Default is None, which doesn't exclude any domains." ) }) // Strict schema with all fields required export const strictSearchSchema = z.object({ query: z.string().describe('The query to search for'), type: z.enum(['general', 'optimized']).describe(getSearchTypeDescription()), content_types: z .array(z.enum(['web', 'video', 'image', 'news'])) .describe( 'Types of content to include in search results. Only applicable when type is "general" and a dedicated general search provider is configured. Ignored otherwise.' ), max_results: z.number().describe('The maximum number of results to return.'), search_depth: z .enum(['basic', 'advanced']) .describe('The depth of the search'), include_domains: z .array(z.string()) .nullish() .transform(val => val ?? []) .describe( 'A list of domains to specifically include in the search results. Default is None, which includes all domains.' ), exclude_domains: z .array(z.string()) .nullish() .transform(val => val ?? []) .describe( "A list of domains to specifically exclude from the search results. Default is None, which doesn't exclude any domains." ) }) /** * Returns the appropriate search schema based on the full model name. * Uses the strict schema for OpenAI models starting with 'o'. */ export function getSearchSchemaForModel(fullModel: string) { const [provider, modelName] = fullModel?.split(':') ?? [] const useStrictSchema = (provider === 'openai' || provider === 'azure') && modelName?.startsWith('o') // Ensure search_depth is an enum for the strict schema if (useStrictSchema) { return strictSearchSchema } else { // For the standard schema, keep search_depth as optional string return searchSchema } } export type PartialInquiry = DeepPartial<typeof searchSchema> ================================================ FILE: lib/storage/r2-client.ts ================================================ import { S3Client } from '@aws-sdk/client-s3' export const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME || 'user-uploads' export const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || '' let _r2Client: S3Client | null = null export function getR2Client(): S3Client { if (_r2Client) { return _r2Client } const accountId = process.env.R2_ACCOUNT_ID const accessKeyId = process.env.R2_ACCESS_KEY_ID const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY if (!accountId || !accessKeyId || !secretAccessKey) { throw new Error('R2 configuration missing') } _r2Client = new S3Client({ region: 'auto', endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId, secretAccessKey } }) return _r2Client } ================================================ FILE: lib/streaming/__tests__/create-ephemeral-chat-stream-response.test.ts ================================================ import { describe, expect, it } from 'vitest' import { createEphemeralChatStreamResponse } from '@/lib/streaming/create-ephemeral-chat-stream-response' describe('createEphemeralChatStreamResponse', () => { it('returns 400 when messages are missing', async () => { const response = await createEphemeralChatStreamResponse({ messages: [], model: { providerId: 'openai', id: 'gpt-4o-mini' } as any, abortSignal: new AbortController().signal, searchMode: 'quick', modelType: 'speed' }) expect(response.status).toBe(400) const text = await response.text() expect(text).toBe('messages are required') }) }) ================================================ FILE: lib/streaming/__tests__/prune-messages-integration.test.ts ================================================ import type { ModelMessage } from 'ai' import { pruneMessages } from 'ai' import { describe, expect, it } from 'vitest' describe('pruneMessages integration', () => { it('should prune messages according to configuration', () => { const messages: ModelMessage[] = [ { role: 'user', content: 'First question' }, { role: 'assistant', content: 'Let me think about that...' }, { role: 'user', content: 'Second question' }, { role: 'assistant', content: 'Here is my response' } ] const pruned = pruneMessages({ messages, reasoning: 'before-last-message', toolCalls: 'before-last-2-messages', emptyMessages: 'remove' }) // Should return pruned messages expect(Array.isArray(pruned)).toBe(true) expect(pruned.length).toBeGreaterThan(0) expect(pruned.length).toBeLessThanOrEqual(messages.length) }) it('should handle messages with text content', () => { const messages: ModelMessage[] = [ { role: 'user', content: 'Question' }, { role: 'assistant', content: 'Answer' }, { role: 'user', content: 'Another question' } ] const pruned = pruneMessages({ messages, reasoning: 'all', toolCalls: 'all', emptyMessages: 'remove' }) // Should keep all messages with text content expect(pruned.length).toBe(messages.length) expect(pruned.every((msg: ModelMessage) => msg.content)).toBe(true) }) it('should preserve message structure', () => { const messages: ModelMessage[] = [ { role: 'user', content: 'Question 1' }, { role: 'assistant', content: 'Answer to question 1' }, { role: 'user', content: 'Question 2' }, { role: 'assistant', content: 'Answer to question 2' } ] const pruned = pruneMessages({ messages, reasoning: 'before-last-message', toolCalls: 'none', emptyMessages: 'remove' }) // Last assistant message should be preserved const lastAssistant = pruned .slice() .reverse() .find((msg: ModelMessage) => msg.role === 'assistant') expect(lastAssistant).toBeDefined() expect(lastAssistant!.content).toBeTruthy() }) it('should handle simple conversation messages', () => { const messages: ModelMessage[] = [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' }, { role: 'user', content: 'How are you?' }, { role: 'assistant', content: 'I am doing well, thank you!' } ] const pruned = pruneMessages({ messages, reasoning: 'before-last-message', toolCalls: 'before-last-2-messages', emptyMessages: 'remove' }) // Should preserve conversation flow expect(pruned.length).toBeGreaterThan(0) expect(pruned.every(msg => msg.content)).toBe(true) }) }) ================================================ FILE: lib/streaming/create-chat-stream-response.ts ================================================ import { consumeStream, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, pruneMessages, smoothStream, UIMessage, UIMessageStreamWriter } from 'ai' import { randomUUID } from 'crypto' import { Langfuse } from 'langfuse' import { researcher } from '@/lib/agents/researcher' import { isTracingEnabled } from '@/lib/utils/telemetry' import { loadChat } from '../actions/chat' import { generateChatTitle } from '../agents/title-generator' import { getMaxAllowedTokens, shouldTruncateMessages, truncateMessages } from '../utils/context-window' import { getTextFromParts } from '../utils/message-utils' import { perfLog, perfTime } from '../utils/perf-logging' import { persistStreamResults } from './helpers/persist-stream-results' import { prepareMessages } from './helpers/prepare-messages' import { streamRelatedQuestions } from './helpers/stream-related-questions' import { stripReasoningParts } from './helpers/strip-reasoning-parts' import type { StreamContext } from './helpers/types' import { BaseStreamConfig } from './types' // Constants const DEFAULT_CHAT_TITLE = 'Untitled' export async function createChatStreamResponse( config: BaseStreamConfig ): Promise<Response> { const { message, model, chatId, userId, trigger, messageId, abortSignal, isNewChat, searchMode, modelType } = config // Verify that chatId is provided if (!chatId) { return new Response('Chat ID is required', { status: 400, statusText: 'Bad Request' }) } // Skip loading chat for new chats optimization let initialChat = null if (!isNewChat) { const loadChatStart = performance.now() // Fetch chat data for authorization check and cache it initialChat = await loadChat(chatId, userId) perfTime('loadChat completed', loadChatStart) // Authorization check: if chat exists, it must belong to the user if (initialChat && initialChat.userId !== userId) { return new Response('You are not allowed to access this chat', { status: 403, statusText: 'Forbidden' }) } } else { perfLog('loadChat skipped for new chat') } // Create parent trace ID for grouping all operations let parentTraceId: string | undefined let langfuse: Langfuse | undefined if (isTracingEnabled()) { parentTraceId = randomUUID() langfuse = new Langfuse() // Create parent trace with name "research" langfuse.trace({ id: parentTraceId, name: 'research', metadata: { chatId, userId, modelId: `${model.providerId}:${model.id}`, trigger } }) } // Create stream context with trace ID const context: StreamContext = { chatId, userId, modelId: `${model.providerId}:${model.id}`, messageId, trigger, initialChat, abortSignal, parentTraceId, // Add parent trace ID to context isNewChat } // Declare titlePromise in outer scope for onFinish access let titlePromise: Promise<string> | undefined // Create the stream const stream = createUIMessageStream<UIMessage>({ execute: async ({ writer }: { writer: UIMessageStreamWriter }) => { try { // Prepare messages for the model const prepareStart = performance.now() perfLog( `prepareMessages - Invoked: trigger=${trigger}, isNewChat=${isNewChat}` ) const messagesToModel = await prepareMessages(context, message) perfTime('prepareMessages completed (stream)', prepareStart) // Get the researcher agent with parent trace ID, search mode, and model type const researchAgent = researcher({ model: context.modelId, modelConfig: model, parentTraceId, searchMode, modelType }) // For OpenAI models, strip reasoning parts from UIMessages before conversion // OpenAI's Responses API requires reasoning items and their following items to be kept together // See: https://github.com/vercel/ai/issues/11036 const isOpenAI = context.modelId.startsWith('openai:') const messagesToConvert = isOpenAI ? stripReasoningParts(messagesToModel) : messagesToModel // Convert to model messages and apply context window management let modelMessages = await convertToModelMessages(messagesToConvert) // Prune messages to reduce token usage while keeping recent context modelMessages = pruneMessages({ messages: modelMessages, reasoning: 'before-last-message', toolCalls: 'before-last-2-messages', emptyMessages: 'remove' }) if (shouldTruncateMessages(modelMessages, model)) { const maxTokens = getMaxAllowedTokens(model) const originalCount = modelMessages.length modelMessages = truncateMessages(modelMessages, maxTokens, model.id) if (process.env.NODE_ENV === 'development') { console.log( `Context window limit reached. Truncating from ${originalCount} to ${modelMessages.length} messages` ) } } // Start title generation in parallel if it's a new chat if (!initialChat && message) { const userContent = getTextFromParts(message.parts) titlePromise = generateChatTitle({ userMessageContent: userContent, modelId: context.modelId, abortSignal, parentTraceId }).catch(error => { console.error('Error generating title:', error) return DEFAULT_CHAT_TITLE }) } const llmStart = performance.now() perfLog( `researchAgent.stream - Start: model=${context.modelId}, searchMode=${searchMode}` ) const result = await researchAgent.stream({ messages: modelMessages, abortSignal, experimental_transform: smoothStream({ chunking: 'word' }) }) result.consumeStream() // Stream with the research agent, including metadata writer.merge( result.toUIMessageStream({ messageMetadata: ({ part }) => { // Send metadata when streaming starts if (part.type === 'start') { return { traceId: parentTraceId, searchMode, modelId: context.modelId } } } }) ) const responseMessages = (await result.response).messages perfTime('researchAgent.stream completed', llmStart) // Generate related questions if (responseMessages && responseMessages.length > 0) { // Find the last user message const lastUserMessage = [...modelMessages] .reverse() .find(msg => msg.role === 'user') const messagesForQuestions = lastUserMessage ? [lastUserMessage, ...responseMessages] : responseMessages await streamRelatedQuestions( writer, messagesForQuestions, abortSignal, parentTraceId ) } } catch (error) { console.error('Stream execution error:', error) throw error // This error will be handled by the onError callback } finally { // Flush Langfuse traces if enabled if (langfuse) { await langfuse.flushAsync() } } }, onError: (error: any) => { // console.error('Stream error:', error) return error instanceof Error ? error.message : String(error) }, onFinish: async ({ responseMessage, isAborted }) => { if (isAborted || !responseMessage) return // Persist stream results to database await persistStreamResults( responseMessage, chatId, userId, titlePromise, parentTraceId, searchMode, context.modelId, context.pendingInitialSave, context.pendingInitialUserMessage ) } }) return createUIMessageStreamResponse({ stream, consumeSseStream: consumeStream }) } ================================================ FILE: lib/streaming/create-ephemeral-chat-stream-response.ts ================================================ import { consumeStream, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, pruneMessages, smoothStream, UIMessage, UIMessageStreamWriter } from 'ai' import { randomUUID } from 'crypto' import { Langfuse } from 'langfuse' import { researcher } from '@/lib/agents/researcher' import { isTracingEnabled } from '@/lib/utils/telemetry' import { getMaxAllowedTokens, shouldTruncateMessages, truncateMessages } from '../utils/context-window' import { streamRelatedQuestions } from './helpers/stream-related-questions' import { stripReasoningParts } from './helpers/strip-reasoning-parts' import { BaseStreamConfig } from './types' type EphemeralStreamConfig = Pick< BaseStreamConfig, 'model' | 'abortSignal' | 'searchMode' | 'modelType' > & { messages: UIMessage[] chatId?: string } export async function createEphemeralChatStreamResponse( config: EphemeralStreamConfig ): Promise<Response> { const { messages, model, abortSignal, searchMode, modelType, chatId } = config if (!messages || messages.length === 0) { return new Response('messages are required', { status: 400, statusText: 'Bad Request' }) } // Create parent trace ID for grouping all operations let parentTraceId: string | undefined let langfuse: Langfuse | undefined if (isTracingEnabled()) { parentTraceId = randomUUID() langfuse = new Langfuse() langfuse.trace({ id: parentTraceId, name: 'research', metadata: { chatId, userId: 'guest', modelId: `${model.providerId}:${model.id}`, trigger: 'submit-message', modelType } }) } const stream = createUIMessageStream<UIMessage>({ execute: async ({ writer }: { writer: UIMessageStreamWriter }) => { try { const isOpenAI = `${model.providerId}:${model.id}`.startsWith('openai:') const messagesToConvert = isOpenAI ? stripReasoningParts(messages) : messages let modelMessages = await convertToModelMessages(messagesToConvert) modelMessages = pruneMessages({ messages: modelMessages, reasoning: 'before-last-message', toolCalls: 'before-last-2-messages', emptyMessages: 'remove' }) if (shouldTruncateMessages(modelMessages, model)) { const maxTokens = getMaxAllowedTokens(model) modelMessages = truncateMessages(modelMessages, maxTokens, model.id) } const researchAgent = researcher({ model: `${model.providerId}:${model.id}`, modelConfig: model, parentTraceId, searchMode, modelType }) const result = await researchAgent.stream({ messages: modelMessages, abortSignal, experimental_transform: smoothStream({ chunking: 'word' }) }) result.consumeStream() writer.merge( result.toUIMessageStream({ messageMetadata: ({ part }) => { if (part.type === 'start') { return { traceId: parentTraceId, searchMode, modelId: `${model.providerId}:${model.id}` } } } }) ) const responseMessages = (await result.response).messages if (responseMessages && responseMessages.length > 0) { const lastUserMessage = [...modelMessages] .reverse() .find(msg => msg.role === 'user') const messagesForQuestions = lastUserMessage ? [lastUserMessage, ...responseMessages] : responseMessages await streamRelatedQuestions( writer, messagesForQuestions, abortSignal, parentTraceId ) } } finally { if (langfuse) { await langfuse.flushAsync() } } }, onError: (error: any) => { return error instanceof Error ? error.message : String(error) } }) return createUIMessageStreamResponse({ stream, consumeSseStream: consumeStream }) } ================================================ FILE: lib/streaming/helpers/__tests__/prepare-messages.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createChatWithFirstMessage, deleteMessagesFromIndex, loadChat, upsertMessage } from '@/lib/actions/chat' import type { Chat } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' import { prepareMessages } from '../prepare-messages' import type { StreamContext } from '../types' // Mock dependencies vi.mock('@/lib/actions/chat') vi.mock('@/lib/db/schema', async () => { const actual = await vi.importActual('@/lib/db/schema') return { ...actual, generateId: vi.fn(() => 'generated-id-123') } }) describe('prepareMessages', () => { const userId = 'user-123' const chatId = 'chat-123' beforeEach(() => { vi.clearAllMocks() }) describe('regenerate-message trigger', () => { it('should reload chat after deleting assistant message', async () => { // Setup: Chat with 4 messages const initialChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] }, { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Question 2' }] }, { id: 'msg-4', role: 'assistant', parts: [{ type: 'text', text: 'Answer 2' }] } ] } // After deletion, only messages 1-3 remain (but we only return 1) const updatedChat: Chat & { messages: UIMessage[] } = { ...initialChat, messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] } ] } vi.mocked(deleteMessagesFromIndex).mockResolvedValue({ success: true, count: 3 }) vi.mocked(loadChat).mockResolvedValue(updatedChat) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'regenerate-message', messageId: 'msg-2', initialChat, isNewChat: false } const result = await prepareMessages(context, null) // Verify deleteMessagesFromIndex was called with correct message expect(deleteMessagesFromIndex).toHaveBeenCalledWith( chatId, 'msg-2', userId ) // Critical: loadChat should be called AFTER deletion to get fresh data expect(loadChat).toHaveBeenCalledWith(chatId, userId) // Verify only message 1 is returned (correct context for regeneration) expect(result).toHaveLength(1) expect(result[0].id).toBe('msg-1') }) it('should reload chat after deleting later assistant message', async () => { // Setup: Chat with 6 messages const initialChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] }, { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Question 2' }] }, { id: 'msg-4', role: 'assistant', parts: [{ type: 'text', text: 'Answer 2' }] }, { id: 'msg-5', role: 'user', parts: [{ type: 'text', text: 'Question 3' }] }, { id: 'msg-6', role: 'assistant', parts: [{ type: 'text', text: 'Answer 3' }] } ] } // After deleting msg-4, messages 1-3 remain const updatedChat: Chat & { messages: UIMessage[] } = { ...initialChat, messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] }, { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Question 2' }] } ] } vi.mocked(deleteMessagesFromIndex).mockResolvedValue({ success: true, count: 3 }) vi.mocked(loadChat).mockResolvedValue(updatedChat) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'regenerate-message', messageId: 'msg-4', initialChat, isNewChat: false } const result = await prepareMessages(context, null) // Verify correct messages deleted expect(deleteMessagesFromIndex).toHaveBeenCalledWith( chatId, 'msg-4', userId ) // Critical: loadChat should be called to get updated state expect(loadChat).toHaveBeenCalledWith(chatId, userId) // Verify messages 1-3 are returned (correct context) expect(result).toHaveLength(3) expect(result[0].id).toBe('msg-1') expect(result[1].id).toBe('msg-2') expect(result[2].id).toBe('msg-3') }) it('should handle user message edit and reload chat', async () => { // Setup: Chat with 4 messages const initialChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] }, { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Question 2' }] }, { id: 'msg-4', role: 'assistant', parts: [{ type: 'text', text: 'Answer 2' }] } ] } const editedMessage: UIMessage = { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Edited Question 2' }] } // After editing msg-3, messages 1-3 remain (msg-4 deleted) const updatedChat: Chat & { messages: UIMessage[] } = { ...initialChat, messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] }, { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Edited Question 2' }] } ] } vi.mocked(upsertMessage).mockResolvedValue({ id: 'msg-3', chatId, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: new Date() }) vi.mocked(deleteMessagesFromIndex).mockResolvedValue({ success: true, count: 1 }) vi.mocked(loadChat).mockResolvedValue(updatedChat) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'regenerate-message', messageId: 'msg-3', initialChat, isNewChat: false } const result = await prepareMessages(context, editedMessage) // Verify message was updated expect(upsertMessage).toHaveBeenCalledWith(chatId, editedMessage, userId) // Verify subsequent messages were deleted expect(deleteMessagesFromIndex).toHaveBeenCalledWith( chatId, 'msg-4', userId ) // Critical: loadChat should be called to get updated state expect(loadChat).toHaveBeenCalledWith(chatId, userId) // Verify updated messages are returned expect(result).toHaveLength(3) expect(result[2].parts[0]).toMatchObject({ type: 'text', text: 'Edited Question 2' }) }) it('should throw error when no messages found in chat', async () => { const emptyChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Empty Chat', userId, visibility: 'private', createdAt: new Date(), messages: [] } const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'regenerate-message', messageId: 'msg-1', initialChat: emptyChat, isNewChat: false } await expect(prepareMessages(context, null)).rejects.toThrow( 'No messages found' ) }) it('should use fallback when message not found by ID', async () => { const initialChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Test Chat', userId, visibility: 'private', createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] }, { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Answer 1' }] } ] } const updatedChat: Chat & { messages: UIMessage[] } = { ...initialChat, messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Question 1' }] } ] } vi.mocked(deleteMessagesFromIndex).mockResolvedValue({ success: true, count: 1 }) vi.mocked(loadChat).mockResolvedValue(updatedChat) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'regenerate-message', messageId: 'non-existent-id', initialChat, isNewChat: false } const result = await prepareMessages(context, null) // Should fallback to last assistant message (msg-2) expect(deleteMessagesFromIndex).toHaveBeenCalled() expect(loadChat).toHaveBeenCalledWith(chatId, userId) expect(result).toHaveLength(1) }) }) describe('submit-message trigger', () => { it('should create new chat with first message optimistically', async () => { const newMessage: UIMessage = { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] } vi.mocked(createChatWithFirstMessage).mockResolvedValue({ chat: { id: chatId, title: 'Untitled', userId, visibility: 'private', createdAt: new Date() }, message: { id: 'msg-1', chatId, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: null } }) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'submit-message', messageId: undefined, initialChat: null, isNewChat: true } const result = await prepareMessages(context, newMessage) // Verify message is returned immediately (optimistic) expect(result).toHaveLength(1) expect(result[0].id).toBe('msg-1') // Verify persistence happens in background expect(context.pendingInitialSave).toBeDefined() expect(context.pendingInitialUserMessage).toEqual({ ...newMessage, id: 'msg-1' }) }) it('should append message to existing chat', async () => { const existingChat: Chat & { messages: UIMessage[] } = { id: chatId, title: 'Existing Chat', userId, visibility: 'private', createdAt: new Date(), messages: [ { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'First message' }] } ] } const newMessage: UIMessage = { id: 'msg-2', role: 'user', parts: [{ type: 'text', text: 'Second message' }] } vi.mocked(upsertMessage).mockResolvedValue({ id: 'msg-2', chatId, role: 'user', metadata: {}, createdAt: new Date(), updatedAt: null }) const context: StreamContext = { chatId, userId, modelId: 'gpt-4', trigger: 'submit-message', messageId: undefined, initialChat: existingChat, isNewChat: false } const result = await prepareMessages(context, newMessage) // Verify message was saved expect(upsertMessage).toHaveBeenCalledWith(chatId, newMessage, userId) // Verify both messages are returned expect(result).toHaveLength(2) expect(result[0].id).toBe('msg-1') expect(result[1].id).toBe('msg-2') }) }) }) ================================================ FILE: lib/streaming/helpers/persist-stream-results.ts ================================================ import { UIMessage } from 'ai' import { createChatWithFirstMessage, upsertMessage } from '@/lib/actions/chat' import { updateChatTitle } from '@/lib/db/actions' import { SearchMode } from '@/lib/types/search' import { perfTime } from '@/lib/utils/perf-logging' import { retryDatabaseOperation } from '@/lib/utils/retry' const DEFAULT_CHAT_TITLE = 'Untitled' export async function persistStreamResults( responseMessage: UIMessage, chatId: string, userId: string, titlePromise?: Promise<string>, parentTraceId?: string, searchMode?: SearchMode, modelId?: string, initialSavePromise?: Promise< Awaited<ReturnType<typeof createChatWithFirstMessage>> >, initialUserMessage?: UIMessage ) { // Attach metadata to the response message responseMessage.metadata = { ...(responseMessage.metadata || {}), ...(parentTraceId && { traceId: parentTraceId }), ...(searchMode && { searchMode }), ...(modelId && { modelId }) } // Wait for title generation if it was started const chatTitle = titlePromise ? await titlePromise : undefined // Ensure the initial chat/message persistence finished before saving the response if (initialSavePromise) { const initialSaveStart = performance.now() try { await initialSavePromise perfTime('initial chat persistence awaited', initialSaveStart) } catch (error) { console.error('Initial chat persistence failed:', error) if (initialUserMessage) { const fallbackStart = performance.now() try { await createChatWithFirstMessage( chatId, initialUserMessage, userId, DEFAULT_CHAT_TITLE ) perfTime('initial chat persistence fallback completed', fallbackStart) } catch (fallbackError) { // Check if the error is due to duplicate key (chat already exists) const isDuplicateKey = fallbackError instanceof Error && (fallbackError.message.includes('duplicate key') || fallbackError.message.includes('unique constraint')) if (isDuplicateKey) { // Chat already exists, this is fine - continue to save the response message console.log( 'Chat already exists (duplicate key), continuing with response save' ) perfTime( 'initial chat persistence - duplicate detected', fallbackStart ) } else { // Other error - log and return console.error('Fallback chat creation failed:', fallbackError) return } } } else { return } } } // Save message with retry logic const saveStart = performance.now() try { await upsertMessage(chatId, responseMessage, userId) perfTime('upsertMessage (AI response) completed', saveStart) } catch (error) { console.error('Error saving message:', error) try { await retryDatabaseOperation( () => upsertMessage(chatId, responseMessage, userId), 'save message' ) perfTime('upsertMessage (AI response) completed after retry', saveStart) } catch (retryError) { console.error('Failed to save after retries:', retryError) // Don't throw here to avoid breaking the stream } } // Update title after message is saved if (chatTitle && chatTitle !== DEFAULT_CHAT_TITLE) { try { await updateChatTitle(chatId, chatTitle, userId) } catch (error) { console.error('Error updating title:', error) // Don't throw here as title update is not critical } } } ================================================ FILE: lib/streaming/helpers/prepare-messages.ts ================================================ import { UIMessage } from 'ai' import { createChat, createChatWithFirstMessage, deleteMessagesFromIndex, loadChat, upsertMessage } from '@/lib/actions/chat' import { generateId } from '@/lib/db/schema' import { perfLog, perfTime } from '@/lib/utils/perf-logging' import type { StreamContext } from './types' const DEFAULT_CHAT_TITLE = 'Untitled' export async function prepareMessages( context: StreamContext, message: UIMessage | null ): Promise<UIMessage[]> { const { chatId, userId, trigger, messageId, initialChat, isNewChat } = context const startTime = performance.now() perfLog(`prepareMessages - Start: trigger=${trigger}, isNewChat=${isNewChat}`) if (trigger === 'regenerate-message' && messageId) { // Handle regeneration - use initialChat if available to avoid DB call let currentChat = initialChat if (!currentChat) { currentChat = await loadChat(chatId, userId) } if (!currentChat || !currentChat.messages.length) { throw new Error('No messages found') } let messageIndex = currentChat.messages.findIndex( (m: any) => m.id === messageId ) // Fallback: If message not found by ID, try to find by position if (messageIndex === -1) { const lastAssistantIndex = currentChat.messages.findLastIndex( (m: any) => m.role === 'assistant' ) const lastUserIndex = currentChat.messages.findLastIndex( (m: any) => m.role === 'user' ) if (lastAssistantIndex >= 0 || lastUserIndex >= 0) { messageIndex = Math.max(lastAssistantIndex, lastUserIndex) } else { throw new Error( `Message ${messageId} not found and no fallback available` ) } } const targetMessage = currentChat.messages[messageIndex] if (targetMessage.role === 'assistant') { await deleteMessagesFromIndex(chatId, messageId, userId) // Reload chat to get the updated message list after deletion const updatedChat = await loadChat(chatId, userId) return ( updatedChat?.messages || currentChat.messages.slice(0, messageIndex) ) } else { // User message edit if (message && message.id === messageId) { await upsertMessage(chatId, message, userId) } const messagesToDelete = currentChat.messages.slice(messageIndex + 1) if (messagesToDelete.length > 0) { await deleteMessagesFromIndex(chatId, messagesToDelete[0].id, userId) } const updatedChat = await loadChat(chatId, userId) return ( updatedChat?.messages || currentChat.messages.slice(0, messageIndex + 1) ) } } else { // Handle normal message submission if (!message) { throw new Error('No message provided') } const messageWithId = { ...message, id: message.id || generateId() } // Optimize for new chats: create chat and save message together if (isNewChat) { // Persist the chat and first message optimistically in the background const createStart = performance.now() const persistencePromise = createChatWithFirstMessage( chatId, messageWithId, userId, DEFAULT_CHAT_TITLE ) .then(result => { perfTime('createChatWithFirstMessage completed', createStart) perfTime('prepareMessages - Total', startTime) return result }) .catch(error => { console.error('Error creating chat with first message:', error) throw error }) context.pendingInitialSave = persistencePromise context.pendingInitialUserMessage = messageWithId perfTime('prepareMessages - Return (optimistic)', startTime) return [messageWithId] } // For existing chats if (!initialChat) { const createStart = performance.now() await createChat(chatId, DEFAULT_CHAT_TITLE, userId) perfTime('createChat completed', createStart) } const upsertStart = performance.now() await upsertMessage(chatId, messageWithId, userId) perfTime('upsertMessage completed', upsertStart) // If we have initialChat, append the new message instead of fetching all messages if (initialChat && initialChat.messages) { perfTime('prepareMessages - Total (using cached chat)', startTime) return [...initialChat.messages, messageWithId] } // Fallback to fetching if no initialChat const loadStart = performance.now() const updatedChat = await loadChat(chatId, userId) perfTime('loadChat (fallback) completed', loadStart) perfTime('prepareMessages - Total', startTime) return updatedChat?.messages || [messageWithId] } } ================================================ FILE: lib/streaming/helpers/stream-related-questions.ts ================================================ import { ModelMessage, UIMessageStreamWriter } from 'ai' import { createRelatedQuestionsStream } from '@/lib/agents/generate-related-questions' import { generateId } from '@/lib/db/schema' import { relatedSchema } from '@/lib/schema/related' /** * Generates and streams related questions if there are tool calls in the response */ export async function streamRelatedQuestions( writer: UIMessageStreamWriter, messages: ModelMessage[], abortSignal?: AbortSignal, parentTraceId?: string ): Promise<{ questionPartId?: string questions?: Array<{ question: string }> }> { // Check if the last message has tool calls const lastMessage = messages[messages.length - 1] if (!lastMessage || lastMessage.role !== 'assistant') { return {} } const questionPartId = generateId() try { // Write loading state writer.write({ type: 'data-relatedQuestions', id: questionPartId, data: { status: 'loading' } }) const relatedQuestionsResult = createRelatedQuestionsStream( messages, abortSignal, parentTraceId ) const collectedQuestions: Array<{ question: string }> = [] for await (const question of relatedQuestionsResult.elementStream) { if (!question || typeof question.question !== 'string') { continue } collectedQuestions.push(question) writer.write({ type: 'data-relatedQuestions', id: questionPartId, data: { status: 'streaming', questions: [...collectedQuestions] } }) } let finalQuestions = collectedQuestions try { const completedQuestions = await relatedQuestionsResult.output const parsedQuestions = relatedSchema.safeParse(completedQuestions) if (parsedQuestions.success) { finalQuestions = parsedQuestions.data } else if (Array.isArray(completedQuestions)) { finalQuestions = completedQuestions console.warn( 'Related questions validation failed:', parsedQuestions.error ) } } catch (error) { console.warn('Error retrieving final related questions object:', error) } writer.write({ type: 'data-relatedQuestions', id: questionPartId, data: { status: 'success', questions: finalQuestions } }) return { questionPartId, questions: finalQuestions } } catch (error) { console.error('Error generating related questions:', error) // Write error state writer.write({ type: 'data-relatedQuestions', id: questionPartId, data: { status: 'error' } }) return { questionPartId } } } ================================================ FILE: lib/streaming/helpers/strip-reasoning-parts.ts ================================================ import { UIMessage } from 'ai' /** * Strips reasoning parts from UIMessages for OpenAI models. * * OpenAI's Responses API requires reasoning items and their following items * (tool-calls or text) to be kept together. The AI SDK's convertToModelMessages * doesn't properly handle these requirements, causing errors like: * "Item 'rs_...' of type 'reasoning' was provided without its required following item" * * By stripping reasoning parts before conversion, we avoid this compatibility issue. * * @see https://github.com/vercel/ai/issues/11036 */ export function stripReasoningParts(messages: UIMessage[]): UIMessage[] { return messages.map(msg => { if (msg.role !== 'assistant' || !msg.parts) { return msg } const filteredParts = msg.parts.filter(part => part.type !== 'reasoning') // If all parts were reasoning, keep the original message if (filteredParts.length === 0) { return msg } return { ...msg, parts: filteredParts } }) } ================================================ FILE: lib/streaming/helpers/types.ts ================================================ import type { Chat, Message } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' export interface StreamContext { chatId: string userId: string modelId: string messageId?: string trigger?: string initialChat: (Chat & { messages: UIMessage[] }) | null abortSignal?: AbortSignal parentTraceId?: string isNewChat?: boolean pendingInitialSave?: Promise<{ chat: Chat; message: Message }> pendingInitialUserMessage?: UIMessage } ================================================ FILE: lib/streaming/types.ts ================================================ import { UIMessage } from '@ai-sdk/react' import { ModelType } from '../types/model-type' import { Model } from '../types/models' import { SearchMode } from '../types/search' export interface BaseStreamConfig { message: UIMessage | null model: Model chatId: string userId: string trigger?: 'submit-user-message' | 'regenerate-assistant-message' messageId?: string abortSignal?: AbortSignal isNewChat?: boolean searchMode?: SearchMode modelType?: ModelType } ================================================ FILE: lib/supabase/client.ts ================================================ import { createBrowserClient } from '@supabase/ssr' export function createClient() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (!url || !key) { console.warn( 'Supabase client configuration missing. Authentication features will be unavailable. ' + 'To enable authentication, set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY at build time.' ) throw new Error('Supabase not configured') } return createBrowserClient(url, key) } ================================================ FILE: lib/supabase/middleware.ts ================================================ import { type NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@supabase/ssr' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll() }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ) supabaseResponse = NextResponse.next({ request }) cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options) ) } } } ) // Do not run code between createServerClient and // supabase.auth.getUser(). A simple mistake could make it very hard to debug // issues with users being randomly logged out. // IMPORTANT: DO NOT REMOVE auth.getUser() const { data: { user } } = await supabase.auth.getUser() // Define public paths that don't require authentication const publicPaths = [ '/', // Root path '/auth', // Auth-related pages '/share', // Share pages '/api' // API routes // Add other public paths here if needed ] const pathname = request.nextUrl.pathname // Redirect to login if the user is not authenticated and the path is not public if (!user && !publicPaths.some(path => pathname.startsWith(path))) { // no user, potentially respond by redirecting the user to the login page const url = request.nextUrl.clone() url.pathname = '/auth/login' return NextResponse.redirect(url) } // IMPORTANT: You *must* return the supabaseResponse object as it is. // If you're creating a new response object with NextResponse.next() make sure to: // 1. Pass the request in it, like so: // const myNewResponse = NextResponse.next({ request }) // 2. Copy over the cookies, like so: // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) // 3. Change the myNewResponse object to fit your needs, but avoid changing // the cookies! // 4. Finally: // return myNewResponse // If this is not done, you may be causing the browser and server to go out // of sync and terminate the user's session prematurely! return supabaseResponse } ================================================ FILE: lib/supabase/server.ts ================================================ import { cookies } from 'next/headers' import { createServerClient } from '@supabase/ssr' export async function createClient() { const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ) } catch { // The `setAll` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } } } } ) } ================================================ FILE: lib/tools/dynamic.ts ================================================ import { dynamicTool } from 'ai' import { z } from 'zod' import type { MCPClient } from '@/lib/types/dynamic-tools' /** * Creates a dynamic tool that can be used for runtime-defined tools * such as MCP tools or user-defined functions */ export function createDynamicTool( name: string, description: string, execute: (input: unknown) => Promise<unknown> ) { return dynamicTool({ description, // Use a flexible schema that accepts any object inputSchema: z.object({}).passthrough(), execute: async input => { try { const result = await execute(input) return result } catch (error) { console.error(`Error executing dynamic tool ${name}:`, error) throw error } } }) } /** * Example: Create an MCP tool wrapper */ export function createMCPTool( toolName: string, description: string, mcpClient: MCPClient ) { return createDynamicTool(`mcp__${toolName}`, description, async input => { // Execute the MCP tool return await mcpClient.callTool(toolName, input) }) } /** * Example: Create a custom user-defined tool */ export function createCustomTool( name: string, description: string, handler: (params: unknown) => Promise<unknown> ) { return createDynamicTool(`dynamic__${name}`, description, handler) } ================================================ FILE: lib/tools/fetch.ts ================================================ import { tool, UIToolInvocation } from 'ai' import { fetchSchema } from '@/lib/schema/fetch' import { SearchResults as SearchResultsType } from '@/lib/types' const CONTENT_CHARACTER_LIMIT = 50000 const TITLE_CHARACTER_LIMIT = 100 async function fetchRegularData(url: string): Promise<SearchResultsType> { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Morphic/1.0)', Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } }) clearTimeout(timeoutId) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const contentType = response.headers.get('content-type') || '' if ( !contentType.includes('text/html') && !contentType.includes('text/plain') ) { throw new Error(`Unsupported content type: ${contentType}`) } const html = await response.text() // Extract title const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i) const rawTitle = titleMatch ? titleMatch[1].trim() : new URL(url).hostname const title = rawTitle.length > TITLE_CHARACTER_LIMIT ? rawTitle.substring(0, TITLE_CHARACTER_LIMIT) + '...' : rawTitle // Process HTML content let processedHtml = html .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles // Replace img tags with alt text or [IMAGE] markers processedHtml = processedHtml .replace(/<img[^>]+alt\s*=\s*["']([^"']+)["'][^>]*>/gi, ' [IMAGE: $1] ') .replace(/<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi, ' [IMAGE] ') .replace(/<img[^>]*>/gi, ' [IMAGE] ') // Extract text content const textContent = processedHtml .replace(/<[^>]*>/g, ' ') // Remove remaining HTML tags .replace(/\s+/g, ' ') // Normalize whitespace .trim() // Limit content length const truncatedContent = textContent.length > CONTENT_CHARACTER_LIMIT ? textContent.substring(0, CONTENT_CHARACTER_LIMIT) + '...[truncated]' : textContent return { results: [ { title, content: truncatedContent, url } ], query: '', images: [] } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error('Request timeout after 10 seconds') } console.error('Fetch error:', error) throw error instanceof Error ? error : new Error('Unknown fetch error') } } async function fetchJinaReaderData(url: string): Promise<SearchResultsType> { try { const response = await fetch(`https://r.jina.ai/${url}`, { method: 'GET', headers: { Accept: 'application/json', 'X-With-Generated-Alt': 'true' } }) const json = await response.json() if (!json.data || json.data.length === 0) { throw new Error('No data returned from Jina Reader API') } const content = json.data.content.slice(0, CONTENT_CHARACTER_LIMIT) return { results: [ { title: json.data.title, content, url: json.data.url } ], query: '', images: [] } } catch (error) { console.error('API Error:', error) throw error instanceof Error ? error : new Error('Jina Reader API failed') } } async function fetchTavilyExtractData(url: string): Promise<SearchResultsType> { try { const apiKey = process.env.TAVILY_API_KEY const response = await fetch('https://api.tavily.com/extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, urls: [url] }) }) const json = await response.json() if (!json.results || json.results.length === 0) { throw new Error('No results returned from content extraction service') } const result = json.results[0] const content = result.raw_content.slice(0, CONTENT_CHARACTER_LIMIT) return { results: [ { title: content.slice(0, TITLE_CHARACTER_LIMIT), content, url: result.url } ], query: '', images: [] } } catch (error) { console.error('API Error:', error) throw error instanceof Error ? error : new Error('Content extraction service failed') } } export const fetchTool = tool({ description: 'Fetch content from any URL. By default uses "regular" type which performs fast, direct HTML fetching without external APIs - ideal for most websites. IMPORTANT: "regular" type does NOT support PDFs and will fail on PDF URLs. Use "api" type when you need: 1) PDF content extraction (required for .pdf URLs), 2) Complex JavaScript-rendered pages, 3) Better markdown formatting, 4) Table extraction. The "api" type requires Jina or Tavily API keys and uses Jina Reader if available, otherwise falls back to Tavily Extract.', inputSchema: fetchSchema, async *execute({ url, type = 'regular' }) { // Yield initial fetching state yield { state: 'fetching' as const, url } let results: SearchResultsType if (type === 'regular') { // Use regular fetch for direct HTML retrieval results = await fetchRegularData(url) } else { // Use API-based extraction (Jina or Tavily) const useJina = process.env.JINA_API_KEY if (useJina) { results = await fetchJinaReaderData(url) } else { results = await fetchTavilyExtractData(url) } } // Yield final results with complete state yield { state: 'complete' as const, ...results } } }) // Export type for UI tool invocation export type FetchUIToolInvocation = UIToolInvocation<typeof fetchTool> ================================================ FILE: lib/tools/question.ts ================================================ import { tool } from 'ai' import { getQuestionSchemaForModel } from '@/lib/schema/question' /** * Creates a question tool with the appropriate schema for the specified model. */ export function createQuestionTool(fullModel: string) { return tool({ description: 'Ask a clarifying question with multiple options when more information is needed', inputSchema: getQuestionSchemaForModel(fullModel) // execute function removed to enable frontend confirmation }) } // Default export for backward compatibility, using a default model export const askQuestionTool = createQuestionTool('openai:gpt-4o-mini') ================================================ FILE: lib/tools/search/providers/base.ts ================================================ import { SearchResults } from '@/lib/types' export interface SearchProvider { search( query: string, maxResults: number, searchDepth: 'basic' | 'advanced', includeDomains: string[], excludeDomains: string[], options?: { type?: 'general' | 'optimized' content_types?: Array<'web' | 'video' | 'image' | 'news'> } ): Promise<SearchResults> } export abstract class BaseSearchProvider implements SearchProvider { abstract search( query: string, maxResults: number, searchDepth: 'basic' | 'advanced', includeDomains: string[], excludeDomains: string[], options?: { type?: 'general' | 'optimized' content_types?: Array<'web' | 'video' | 'image' | 'news'> } ): Promise<SearchResults> protected validateApiKey( key: string | undefined, providerName: string ): asserts key is string { if (!key) { throw new Error( `${providerName}_API_KEY is not set in the environment variables` ) } } protected validateApiUrl( url: string | undefined, providerName: string ): void { if (!url) { throw new Error( `${providerName}_API_URL is not set in the environment variables` ) } } } ================================================ FILE: lib/tools/search/providers/brave.ts ================================================ import { SearchImageItem, SearchResults, SerperSearchResultItem } from '@/lib/types' import { SearchProvider } from './base' interface BraveWebResult { title?: string description?: string url: string } interface BraveVideoResult { title?: string description?: string url?: string thumbnail?: { src?: string } video?: { duration?: string } duration?: string date?: string publisher?: string } interface BraveImageResult { title?: string source?: string url?: string thumbnail?: { src?: string } properties?: { thumbnail?: string width?: number height?: number } width?: number height?: number } export class BraveSearchProvider implements SearchProvider { private apiKey: string | undefined constructor() { this.apiKey = process.env.BRAVE_SEARCH_API_KEY } private getImageThumbnailUrl(result: BraveImageResult): string { return ( result.thumbnail?.src ?? result.properties?.thumbnail ?? result.url ?? '' ) } async search( query: string, maxResults: number = 10, searchDepth?: 'basic' | 'advanced', includeDomains?: string[], excludeDomains?: string[], options?: { type?: 'general' | 'optimized' content_types?: Array<'web' | 'video' | 'image' | 'news'> } ): Promise<SearchResults> { if (!this.apiKey) { throw new Error('Brave Search API key not configured') } const contentTypes = options?.content_types || ['web'] const results: SearchResults = { results: [], images: [], videos: [], query, number_of_results: 0 } // Execute searches in parallel for each content type const promises: Promise<void>[] = [] if (contentTypes.includes('web')) { promises.push(this.searchWeb(query, maxResults, results)) } if (contentTypes.includes('video')) { promises.push(this.searchVideos(query, maxResults, results)) } if (contentTypes.includes('image')) { promises.push(this.searchImages(query, maxResults, results)) } await Promise.all(promises) // Update total count results.number_of_results = results.results.length return results } private async searchWeb( query: string, maxResults: number, results: SearchResults ): Promise<void> { try { const response = await fetch( `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent( query )}&count=${maxResults}`, { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': this.apiKey! } } ) if (!response.ok) { console.error(`Brave web search failed: ${response.statusText}`) throw new Error('Search failed') } const data = await response.json() results.results = (data.web?.results || []) .slice(0, maxResults) .map((result: BraveWebResult) => ({ title: result.title || 'No title', description: result.description || 'No description available', url: result.url })) } catch (error) { console.error('Brave web search error:', error) } } private async searchVideos( query: string, maxResults: number, results: SearchResults ): Promise<void> { try { const response = await fetch( `https://api.search.brave.com/res/v1/videos/search?q=${encodeURIComponent( query )}&count=${maxResults}`, { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': this.apiKey! } } ) if (!response.ok) { console.error(`Brave video search failed: ${response.statusText}`) throw new Error('Search failed') } const data = await response.json() // Convert to SerperSearchResultItem format for compatibility results.videos = (data.results || []).slice(0, maxResults).map( (result: BraveVideoResult, index: number) => ({ title: result.title ?? 'No title', link: result.url ?? '', snippet: result.description ?? 'No description available', imageUrl: result.thumbnail?.src ?? '', duration: result.video?.duration ?? result.duration ?? '', source: result.publisher ?? '', channel: result.publisher ?? '', date: result.date ?? '', position: index }) as SerperSearchResultItem ) } catch (error) { console.error('Brave video search error:', error) results.videos = [] } } private async searchImages( query: string, maxResults: number, results: SearchResults ): Promise<void> { try { const response = await fetch( `https://api.search.brave.com/res/v1/images/search?q=${encodeURIComponent( query )}&count=${maxResults}`, { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': this.apiKey! } } ) if (!response.ok) { console.error(`Brave image search failed: ${response.statusText}`) throw new Error('Search failed') } const data = await response.json() results.images = (data.results || []).slice(0, maxResults).map( (result: BraveImageResult) => ({ title: result.title || 'No title', link: result.url || result.source || '', thumbnailUrl: this.getImageThumbnailUrl(result) }) as SearchImageItem ) } catch (error) { console.error('Brave image search error:', error) results.images = [] } } } ================================================ FILE: lib/tools/search/providers/exa.ts ================================================ import Exa from 'exa-js' import { SearchResults } from '@/lib/types' import { BaseSearchProvider } from './base' export class ExaSearchProvider extends BaseSearchProvider { async search( query: string, maxResults: number = 10, _searchDepth: 'basic' | 'advanced' = 'basic', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearchResults> { const apiKey = process.env.EXA_API_KEY this.validateApiKey(apiKey, 'EXA') const exa = new Exa(apiKey) const exaResults = await exa.searchAndContents(query, { highlights: true, numResults: maxResults, includeDomains, excludeDomains }) return { results: exaResults.results.map((result: any) => ({ title: result.title, url: result.url, content: result.highlight || result.text })), query, images: [], number_of_results: exaResults.results.length } } } ================================================ FILE: lib/tools/search/providers/firecrawl.ts ================================================ import { FirecrawlClient, FirecrawlImageResult, FirecrawlNewsResult, FirecrawlWebResult } from '@/lib/firecrawl' import { BaseSearchProvider } from '@/lib/tools/search/providers/base' import { SearchResults } from '@/lib/types' export class FirecrawlSearchProvider extends BaseSearchProvider { async search( query: string, maxResults: number = 10, searchDepth: 'basic' | 'advanced' = 'basic', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearchResults> { const apiKey = process.env.FIRECRAWL_API_KEY this.validateApiKey(apiKey, 'FIRECRAWL') const firecrawl = new FirecrawlClient(apiKey) const sources: ('web' | 'news' | 'images')[] = ['web'] if (searchDepth === 'advanced') { sources.push('news') } sources.push('images') const response = await firecrawl.search({ query, sources, limit: maxResults // Note: Firecrawl Search API does not support includeDomains/excludeDomains yet... }) const resources: (FirecrawlWebResult | FirecrawlNewsResult)[] = [ ...(response.data?.web || []), ...(response.data?.news || []) ] const results = resources.map(resource => { if ('markdown' in resource) { const markdown = resource.markdown.slice(0, 1000) return { title: resource.title || '', url: resource.url, content: markdown || resource.description || '' } } return { title: resource.title || '', url: resource.url, content: resource.snippet || '' } }) const images = response.data?.images?.map((img: FirecrawlImageResult) => ({ url: img.imageUrl, description: img.title || '' })) || [] return { results, query, images, number_of_results: results.length } } } ================================================ FILE: lib/tools/search/providers/index.ts ================================================ import { SearchProvider } from './base' import { BraveSearchProvider } from './brave' import { ExaSearchProvider } from './exa' import { FirecrawlSearchProvider } from './firecrawl' import { SearXNGSearchProvider } from './searxng' import { TavilySearchProvider } from './tavily' export type SearchProviderType = | 'tavily' | 'exa' | 'searxng' | 'firecrawl' | 'brave' export const DEFAULT_PROVIDER: SearchProviderType = 'tavily' export function createSearchProvider( type?: SearchProviderType ): SearchProvider { const providerType = type || (process.env.SEARCH_API as SearchProviderType) || DEFAULT_PROVIDER switch (providerType) { case 'tavily': return new TavilySearchProvider() case 'exa': return new ExaSearchProvider() case 'searxng': return new SearXNGSearchProvider() case 'brave': return new BraveSearchProvider() case 'firecrawl': return new FirecrawlSearchProvider() default: // Default to TavilySearchProvider if an unknown provider is specified return new TavilySearchProvider() } } export { BraveSearchProvider } from './brave' export type { ExaSearchProvider } from './exa' export type { FirecrawlSearchProvider } from './firecrawl' export { SearXNGSearchProvider } from './searxng' export { TavilySearchProvider } from './tavily' export type { SearchProvider } ================================================ FILE: lib/tools/search/providers/searxng.ts ================================================ import { SearchResultItem, SearchResults, SearXNGResponse, SearXNGResult } from '@/lib/types' import { BaseSearchProvider } from './base' export class SearXNGSearchProvider extends BaseSearchProvider { async search( query: string, maxResults: number = 10, searchDepth: 'basic' | 'advanced' = 'basic', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearchResults> { const apiUrl = process.env.SEARXNG_API_URL this.validateApiUrl(apiUrl, 'SEARXNG') try { // Construct the URL with query parameters const url = new URL(`${apiUrl}/search`) url.searchParams.append('q', query) url.searchParams.append('format', 'json') url.searchParams.append('categories', 'general,images') // Apply search depth settings if (searchDepth === 'advanced') { url.searchParams.append('time_range', '') url.searchParams.append('safesearch', '0') url.searchParams.append('engines', 'google,bing,duckduckgo,wikipedia') } else { url.searchParams.append('time_range', 'year') url.searchParams.append('safesearch', '1') url.searchParams.append('engines', 'google,bing') } // Apply domain filters if provided if (includeDomains.length > 0) { url.searchParams.append('site', includeDomains.join(',')) } // Fetch results from SearXNG const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }) if (!response.ok) { const errorText = await response.text() console.error(`SearXNG API error (${response.status}):`, errorText) throw new Error('Search failed') } const data: SearXNGResponse = await response.json() // Separate general results and image results, and limit to maxResults const generalResults = data.results .filter(result => !result.img_src) .slice(0, maxResults) const imageResults = data.results .filter(result => result.img_src) .slice(0, maxResults) // Format the results to match the expected SearchResults structure return { results: generalResults.map( (result: SearXNGResult): SearchResultItem => ({ title: result.title, url: result.url, content: result.content }) ), query: data.query, images: imageResults .map(result => { const imgSrc = result.img_src || '' return imgSrc.startsWith('http') ? imgSrc : `${apiUrl}${imgSrc}` }) .filter(Boolean), number_of_results: data.number_of_results } } catch (error) { console.error('SearXNG API error:', error) throw error } } } ================================================ FILE: lib/tools/search/providers/tavily.ts ================================================ import { SearchResultImage, SearchResults } from '@/lib/types' import { sanitizeUrl } from '@/lib/utils' import { BaseSearchProvider } from './base' export class TavilySearchProvider extends BaseSearchProvider { async search( query: string, maxResults: number = 10, searchDepth: 'basic' | 'advanced' = 'basic', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearchResults> { const apiKey = process.env.TAVILY_API_KEY this.validateApiKey(apiKey, 'TAVILY') // Tavily API requires a minimum of 5 characters in the query const filledQuery = query.length < 5 ? query + ' '.repeat(5 - query.length) : query const includeImageDescriptions = true const response = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, query: filledQuery, max_results: Math.max(maxResults, 5), search_depth: searchDepth, include_images: true, include_image_descriptions: includeImageDescriptions, include_answers: true, include_domains: includeDomains, exclude_domains: excludeDomains }) }) if (!response.ok) { console.error( `Tavily API error: ${response.status} ${response.statusText}` ) throw new Error('Search failed') } const data = await response.json() const processedImages = includeImageDescriptions ? data.images .map( ({ url, description }: { url: string; description: string }) => ({ url: sanitizeUrl(url), description }) ) .filter( ( image: SearchResultImage ): image is { url: string; description: string } => typeof image === 'object' && image.description !== undefined && image.description !== '' ) : data.images.map((url: string) => sanitizeUrl(url)) return { ...data, images: processedImages } } } ================================================ FILE: lib/tools/search.ts ================================================ import { tool, UIToolInvocation } from 'ai' import { getSearchSchemaForModel } from '@/lib/schema/search' import { SearchResultItem, SearchResults } from '@/lib/types' import { getGeneralSearchProviderType, getSearchToolDescription } from '@/lib/utils/search-config' import { getBaseUrlString } from '@/lib/utils/url' import { createSearchProvider, DEFAULT_PROVIDER, SearchProviderType } from './search/providers' /** * Creates a search tool with the appropriate schema for the given model. */ export function createSearchTool(fullModel: string) { return tool({ description: getSearchToolDescription(), inputSchema: getSearchSchemaForModel(fullModel), async *execute( { query, type = 'optimized', content_types = ['web'], max_results = 20, search_depth = 'basic', // Default for standard schema include_domains = [], exclude_domains = [] }, context ) { // Yield initial searching state yield { state: 'searching' as const, query } // Ensure max_results is at least 10 const minResults = 10 const effectiveMaxResults = Math.max( max_results || minResults, minResults ) const effectiveSearchDepth = search_depth as 'basic' | 'advanced' // Use the original query as is - any provider-specific handling will be done in the provider const filledQuery = query let searchResult: SearchResults // Determine which provider to use based on type let searchAPI: SearchProviderType if (type === 'general') { // Try to use dedicated general search provider const generalProvider = getGeneralSearchProviderType() if (generalProvider) { searchAPI = generalProvider } else { // Fallback to primary provider (optimized search provider) searchAPI = (process.env.SEARCH_API as SearchProviderType) || DEFAULT_PROVIDER console.log( `[Search] type="general" requested but no dedicated provider available, using optimized search provider: ${searchAPI}` ) } } else { // For 'optimized', use the configured provider searchAPI = (process.env.SEARCH_API as SearchProviderType) || DEFAULT_PROVIDER } const effectiveSearchDepthForAPI = searchAPI === 'searxng' && process.env.SEARXNG_DEFAULT_DEPTH === 'advanced' ? 'advanced' : effectiveSearchDepth || 'basic' console.log( `Using search API: ${searchAPI}, Type: ${type}, Search Depth: ${effectiveSearchDepthForAPI}` ) try { if ( searchAPI === 'searxng' && effectiveSearchDepthForAPI === 'advanced' ) { // Get the base URL using the centralized utility function const baseUrl = await getBaseUrlString() const response = await fetch(`${baseUrl}/api/advanced-search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: filledQuery, maxResults: effectiveMaxResults, searchDepth: effectiveSearchDepthForAPI, includeDomains: include_domains, excludeDomains: exclude_domains }) }) if (!response.ok) { throw new Error( `Advanced search API error: ${response.status} ${response.statusText}` ) } searchResult = await response.json() } else { // Use the provider factory to get the appropriate search provider const searchProvider = createSearchProvider(searchAPI) // Pass content_types only for Brave provider if (searchAPI === 'brave') { searchResult = await searchProvider.search( filledQuery, effectiveMaxResults, effectiveSearchDepthForAPI, include_domains, exclude_domains, { type: type as 'general' | 'optimized', content_types: content_types as Array< 'web' | 'video' | 'image' | 'news' > } ) } else { searchResult = await searchProvider.search( filledQuery, effectiveMaxResults, effectiveSearchDepthForAPI, include_domains, exclude_domains ) } } } catch (error) { console.error('Search API error:', error) // Re-throw the error to let AI SDK handle it properly throw error instanceof Error ? error : new Error('Unknown search error') } // Add citation mapping and toolCallId to search results if (searchResult.results && searchResult.results.length > 0) { const citationMap: Record<number, SearchResultItem> = {} searchResult.results.forEach((result, index) => { citationMap[index + 1] = result // Citation numbers start at 1 }) searchResult.citationMap = citationMap } // Add toolCallId from context if (context?.toolCallId) { searchResult.toolCallId = context.toolCallId } console.log('completed search') // Yield final results with complete state yield { state: 'complete' as const, ...searchResult } } }) } // Default export for backward compatibility, using a default model export const searchTool = createSearchTool('openai:gpt-4o-mini') // Export type for UI tool invocation export type SearchUIToolInvocation = UIToolInvocation<typeof searchTool> export async function search( query: string, maxResults: number = 10, searchDepth: 'basic' | 'advanced' = 'basic', includeDomains: string[] = [], excludeDomains: string[] = [] ): Promise<SearchResults> { const result = await searchTool.execute?.( { query, type: 'general', content_types: ['web'], max_results: maxResults, search_depth: searchDepth, include_domains: includeDomains, exclude_domains: excludeDomains }, { toolCallId: 'search', messages: [] } ) if (!result) { return { results: [], images: [], query, number_of_results: 0 } } // Handle AsyncIterable case if (Symbol.asyncIterator in result) { // Collect all results from the async iterable let searchResults: SearchResults | null = null for await (const chunk of result) { // Only assign when we get the complete result if ('state' in chunk && chunk.state === 'complete') { const { state, ...rest } = chunk searchResults = rest as SearchResults } } return ( searchResults ?? { results: [], images: [], query, number_of_results: 0 } ) } return result as SearchResults } ================================================ FILE: lib/tools/todo.ts ================================================ import { tool } from 'ai' import { z } from 'zod' // Todo item schema export const todoItemSchema = z.object({ id: z.string().describe('Unique identifier for the todo item'), content: z.string().describe('The task description'), status: z .enum(['pending', 'in_progress', 'completed']) .describe('Current status of the task'), priority: z .enum(['high', 'medium', 'low']) .default('medium') .describe('Priority level of the task'), timestamp: z.string().describe('ISO timestamp when the todo was created') }) export type TodoItem = z.infer<typeof todoItemSchema> // Schema for todo write tool export const todoWriteInputSchema = z.object({ todos: z.array(todoItemSchema).describe('The complete list of todos'), progressMessage: z .string() .optional() .describe('A brief message about the current progress') }) // Create todo tools with session-scoped storage export function createTodoTools() { // Session-scoped todos storage - isolated per tool instance let sessionTodos: TodoItem[] = [] const todoWrite = tool({ description: 'Create or update todos to track progress on complex tasks. Use this to maintain a list of action items. The response includes completedCount and totalCount to verify task completion.', inputSchema: todoWriteInputSchema, execute: async ({ todos, progressMessage }) => { // Update session todos - ensure priority is always set sessionTodos = todos.map(todo => ({ ...todo, priority: todo.priority || 'medium' })) // Calculate progress const completedCount = todos.filter(t => t.status === 'completed').length const totalCount = todos.length return { success: true, message: progressMessage || `Updated ${totalCount} todos`, completedCount, totalCount, todos: sessionTodos } } }) return { todoWrite } } ================================================ FILE: lib/types/agent.ts ================================================ import type { InferAgentUIMessage, InferUITools, ToolLoopAgent, UIMessage, UIToolInvocation } from 'ai' import type { fetchTool } from '../tools/fetch' import type { createQuestionTool } from '../tools/question' import type { createSearchTool } from '../tools/search' import type { createTodoTools } from '../tools/todo' // Define the tools type for researcher agent export type ResearcherTools = { search: ReturnType<typeof createSearchTool> fetch: typeof fetchTool askQuestion: ReturnType<typeof createQuestionTool> } & ReturnType<typeof createTodoTools> // Type alias for the researcher agent using ToolLoopAgent // ToolLoopAgent generic signature is <CALL_OPTIONS, TOOLS, OUTPUT> export type ResearcherAgent = ToolLoopAgent<never, ResearcherTools, never> // Infer UI message type for researcher agent export type ResearcherUIMessage = InferAgentUIMessage<ResearcherAgent> // Infer UI tools type for researcher agent export type ResearcherUITools = InferUITools<ResearcherTools> // Tool invocation types for each tool export type SearchToolInvocation = UIToolInvocation<ResearcherTools['search']> export type FetchToolInvocation = UIToolInvocation<ResearcherTools['fetch']> export type QuestionToolInvocation = UIToolInvocation< ResearcherTools['askQuestion'] > export type TodoWriteToolInvocation = UIToolInvocation< ResearcherTools['todoWrite'] > // Union type for all tool invocations export type ResearcherToolInvocation = | SearchToolInvocation | FetchToolInvocation | QuestionToolInvocation | TodoWriteToolInvocation // Helper type to extract tool names export type ResearcherToolName = keyof ResearcherTools // Type guard functions export function isSearchToolInvocation( invocation: ResearcherToolInvocation ): invocation is SearchToolInvocation { return 'query' in (invocation as any).input } export function isFetchToolInvocation( invocation: ResearcherToolInvocation ): invocation is FetchToolInvocation { return 'url' in (invocation as any).input } // Response type for agent.respond() export type ResearcherResponse = Response // Options type for agent.respond() export type ResearcherRespondOptions = { messages: UIMessage<never, never, ResearcherUITools>[] } ================================================ FILE: lib/types/ai.ts ================================================ import type { ReasoningPart, TextPart } from '@ai-sdk/provider-utils' import type { InferUITool, UIMessage as AIMessage } from 'ai' import { fetchTool } from '@/lib/tools/fetch' import { askQuestionTool } from '@/lib/tools/question' import { searchTool } from '@/lib/tools/search' import { createTodoTools, type TodoItem } from '@/lib/tools/todo' import type { SearchMode } from '@/lib/types/search' // Re-export TodoItem for external use export type { TodoItem } // Define metadata type for messages export interface UIMessageMetadata { traceId?: string feedbackScore?: number | null searchMode?: SearchMode modelId?: string [key: string]: any } export type UIMessage< TMetadata = UIMessageMetadata, TDataTypes = UIDataTypes, TTools = UITools > = AIMessage export interface RelatedQuestionsData { status: 'loading' | 'streaming' | 'success' | 'error' questions?: Array<{ question: string }> } export type UIDataTypes = { sources?: any[] relatedQuestions?: RelatedQuestionsData } // Data part types for DataSection export type DataRelatedQuestionsPart = { type: 'data-relatedQuestions' id?: string data: RelatedQuestionsData } export type DataPart = DataRelatedQuestionsPart // Create todo tools instance for type inference const todoTools = createTodoTools() export type UITools = { search: InferUITool<typeof searchTool> fetch: InferUITool<typeof fetchTool> askQuestion: InferUITool<typeof askQuestionTool> todoWrite: InferUITool<typeof todoTools.todoWrite> // Dynamic tools will be added at runtime [key: string]: any } export type ToolPart<T extends keyof UITools = keyof UITools> = { type: `tool-${T}` toolCallId: string input: UITools[T]['input'] output?: UITools[T]['output'] state: | 'input-streaming' | 'input-available' | 'output-available' | 'output-error' errorText?: string } export type Part = TextPart | ReasoningPart | ToolPart ================================================ FILE: lib/types/dynamic-tools.ts ================================================ /** * Type definitions for dynamic tools */ // MCP Client interface export interface MCPClient { connect(): Promise<void> disconnect(): Promise<void> callTool(toolName: string, input: unknown): Promise<unknown> sendRequest(request: object): Promise<object> } // Dynamic tool configuration export interface DynamicToolConfig { name: string description: string handler?: (params: unknown) => Promise<unknown> mcpClient?: MCPClient } // Dynamic tool part types matching AI SDK v5 export type DynamicToolPart = | DynamicToolPartInputStreaming | DynamicToolPartInputAvailable | DynamicToolPartOutputAvailable | DynamicToolPartOutputError interface DynamicToolPartBase { type: 'dynamic-tool' toolCallId: string toolName: string } export interface DynamicToolPartInputStreaming extends DynamicToolPartBase { state: 'input-streaming' input: unknown } export interface DynamicToolPartInputAvailable extends DynamicToolPartBase { state: 'input-available' input: unknown } export interface DynamicToolPartOutputAvailable extends DynamicToolPartBase { state: 'output-available' input: unknown output: unknown } export interface DynamicToolPartOutputError extends DynamicToolPartBase { state: 'output-error' input: unknown errorText: string } // Type guards export function isDynamicToolPart(part: unknown): part is DynamicToolPart { return ( typeof part === 'object' && part !== null && 'type' in part && part.type === 'dynamic-tool' ) } export function isToolCallPart( part: unknown ): part is { type: 'tool-call'; toolCallId: string; toolName: string } { return ( typeof part === 'object' && part !== null && 'type' in part && part.type === 'tool-call' ) } export function isToolTypePart( part: unknown ): part is { type: string; toolCallId: string } { return ( typeof part === 'object' && part !== null && 'type' in part && 'toolCallId' in part && typeof part.type === 'string' && part.type.startsWith('tool-') ) } ================================================ FILE: lib/types/index.ts ================================================ // Re-export SearchMode for convenience export type { SearchMode } from './search' export type SearchResults = { images: SearchResultImage[] results: SearchResultItem[] videos?: SerperSearchResultItem[] number_of_results?: number query: string toolCallId?: string // ID of the search tool call citationMap?: Record<number, SearchResultItem> // Maps citation number to search result } // If enabled the include_images_description is true, the images will be an array of { url: string, description: string } // Otherwise, the images will be an array of strings export type SearchResultImage = | string | { url: string description: string number_of_results?: number } export type ExaSearchResults = { results: ExaSearchResultItem[] } export type SerperSearchResults = { searchParameters: { q: string type: string engine: string } videos: SerperSearchResultItem[] } export type SearchResultItem = { title: string url: string content: string } export type ExaSearchResultItem = { score: number title: string id: string url: string publishedDate: Date author: string } export type SerperSearchResultItem = { title: string link: string snippet: string imageUrl: string duration: string source: string channel: string date: string position: number } export type SearchImageItem = { title: string link: string thumbnailUrl: string } export interface SearXNGResult { title: string url: string content: string img_src?: string publishedDate?: string score?: number } export interface SearXNGResponse { query: string number_of_results: number results: SearXNGResult[] } export type SearXNGImageResult = string export type SearXNGSearchResults = { images: SearXNGImageResult[] results: SearchResultItem[] number_of_results?: number query: string } export type UploadedFile = { file: File status: 'uploading' | 'uploaded' | 'error' url?: string name?: string key?: string } ================================================ FILE: lib/types/message-persistence.ts ================================================ import { z } from 'zod' import type { parts } from '@/lib/db/schema' import type { UIMessage } from '@/lib/types/ai' // Metadata schema export const metadataSchema = z.object({}) export type Metadata = z.infer<typeof metadataSchema> // Data part definition (extensible) export const dataPartSchema = z.object({}).passthrough() export type DataPart = z.infer<typeof dataPartSchema> // Provider metadata export type ProviderMetadata = Record<string, any> // DB type definitions export type DBMessagePart = typeof parts.$inferInsert export type DBMessagePartSelect = typeof parts.$inferSelect // Tool states export type ToolState = | 'input-streaming' | 'input-available' | 'output-available' | 'output-error' // Dynamic tool type definitions (includes MCP and other runtime tools) export type DynamicToolInput = { toolName: string params: unknown } export type DynamicToolOutput = unknown // Dynamic tool type for storage export type DynamicToolType = 'mcp' | 'dynamic' | 'custom' // Common MCP tool type definition examples export type MCPGitHubInput = { toolName: 'mcp__github__create_issue' params: { owner: string repo: string title: string body?: string } } // DB message type export type DBMessage = { id: string chatId: string role: string createdAt: Date | string } // Extended UIMessage type (persistence support) export type PersistableUIMessage = UIMessage & { id: string chatId?: string } ================================================ FILE: lib/types/model-type.ts ================================================ // Model type definition for speed/quality selection export type ModelType = 'speed' | 'quality' ================================================ FILE: lib/types/models.ts ================================================ export interface Model { id: string name: string provider: string providerId: string providerOptions?: Record<string, any> } ================================================ FILE: lib/types/search.ts ================================================ // Search mode type definition export type SearchMode = 'quick' | 'adaptive' ================================================ FILE: lib/utils/__tests__/citation.test.ts ================================================ import { describe, expect, it } from 'vitest' import type { SearchResultItem } from '@/lib/types' import { processCitations } from '../citation' describe('processCitations', () => { const mockCitationMaps = { toolCall1: { 1: { title: 'Google', url: 'https://www.google.com', content: 'Search engine' }, 2: { title: 'GitHub', url: 'https://docs.github.com', content: 'Developer platform' }, 3: { title: 'Stack Overflow', url: 'https://stackoverflow.com/questions/123', content: 'Q&A for developers' } } as Record<number, SearchResultItem> } it('converts numbered citations to domain names', () => { const content = 'Check out [1](#toolCall1) and [2](#toolCall1)' const result = processCitations(content, mockCitationMaps) expect(result).toBe( 'Check out [google](https://www.google.com) and [github](https://docs.github.com)' ) }) it('handles citations with spaces', () => { const content = 'See [ 1 ](#toolCall1) for details' const result = processCitations(content, mockCitationMaps) expect(result).toBe('See [google](https://www.google.com) for details') }) it('handles multiple citations from same domain', () => { const citationMaps = { toolCall1: { 1: { title: 'Google Search', url: 'https://www.google.com/search', content: 'Search' }, 2: { title: 'Google Maps', url: 'https://www.google.com/maps', content: 'Maps' } } as Record<number, SearchResultItem> } const content = 'Try [1](#toolCall1) or [2](#toolCall1)' const result = processCitations(content, citationMaps) expect(result).toBe( 'Try [google](https://www.google.com/search) or [google](https://www.google.com/maps)' ) }) it('returns empty string for invalid citation numbers', () => { const content = 'Invalid [999](#toolCall1) citation' const result = processCitations(content, mockCitationMaps) expect(result).toBe('Invalid citation') }) it('returns empty string for missing toolCallId', () => { const content = 'Missing [1](#nonExistentTool) tool' const result = processCitations(content, mockCitationMaps) expect(result).toBe('Missing tool') }) it('returns empty string for invalid URLs', () => { const citationMaps = { toolCall1: { 1: { title: 'Invalid', url: 'not-a-valid-url', content: 'Invalid URL' } } as Record<number, SearchResultItem> } const content = 'Check [1](#toolCall1) here' const result = processCitations(content, citationMaps) expect(result).toBe('Check here') }) it('handles content with no citations', () => { const content = 'This is plain text without citations' const result = processCitations(content, mockCitationMaps) expect(result).toBe('This is plain text without citations') }) it('returns empty string for null/undefined content', () => { expect(processCitations('', mockCitationMaps)).toBe('') expect(processCitations(null as any, mockCitationMaps)).toBe('') }) it('handles empty citation maps', () => { const content = 'Text with [1](#toolCall1) citation' const result = processCitations(content, {}) // When citation maps are empty, content is returned unchanged expect(result).toBe('Text with [1](#toolCall1) citation') }) it('encodes URLs to prevent injection', () => { const citationMaps = { toolCall1: { 1: { title: 'Test', url: 'https://example.com/page?param=value&other=test', content: 'Test' } } as Record<number, SearchResultItem> } const content = 'See [1](#toolCall1)' const result = processCitations(content, citationMaps) expect(result).toContain('example') expect(result).toContain('https://example.com/page?param=value&other=test') }) it('handles complex real-world scenarios', () => { const content = `According to [1](#toolCall1), the answer is 42. However, [2](#toolCall1) suggests otherwise. For more information, see [3](#toolCall1).` const result = processCitations(content, mockCitationMaps) expect(result).toContain('[google](https://www.google.com)') expect(result).toContain('[github](https://docs.github.com)') expect(result).toContain( '[stackoverflow](https://stackoverflow.com/questions/123)' ) }) it('handles citation numbers at edge cases', () => { const content = 'Edge cases: [0](#toolCall1) [101](#toolCall1) [-1](#toolCall1)' const result = processCitations(content, mockCitationMaps) // 0 and 101 are out of bounds (1-100), so they're replaced with empty string // -1 doesn't match the regex pattern \d+, so it remains unchanged expect(result).toBe('Edge cases: [-1](#toolCall1)') }) }) ================================================ FILE: lib/utils/__tests__/context-window.test.ts ================================================ import { ModelMessage } from 'ai' import { describe, expect, test } from 'vitest' import { Model } from '@/lib/types/models' import { getMaxAllowedTokens, shouldTruncateMessages, truncateMessages } from '../context-window' describe('context-window', () => { const mockModel: Model = { id: 'gpt-4o-mini', name: 'GPT-4o mini', provider: 'OpenAI', providerId: 'openai' } const createMessage = ( role: 'user' | 'assistant', content: string ): ModelMessage => ({ role, content }) describe('getMaxAllowedTokens', () => { test('calculates max tokens correctly for known model', () => { const maxTokens = getMaxAllowedTokens(mockModel) // Expected: (128000 - 16384) - (128000 * 0.1) = 111616 - 12800 = 98816 expect(maxTokens).toBe(98816) }) test('uses default values for unknown model', () => { const unknownModel: Model = { ...mockModel, id: 'unknown-model' } const maxTokens = getMaxAllowedTokens(unknownModel) // Expected: (16384 - 4096) - (16384 * 0.1) = 12288 - 1638.4 = 10649.6 -> 10650 expect(maxTokens).toBe(10650) }) test('ensures minimum viable token count', () => { // This would need a model with very small context window to test // For now, verify the function returns at least 1000 const maxTokens = getMaxAllowedTokens(mockModel) expect(maxTokens).toBeGreaterThanOrEqual(1000) }) }) describe('shouldTruncateMessages', () => { test('returns false for empty messages', () => { expect(shouldTruncateMessages([], mockModel)).toBe(false) }) test('returns false when under limit', () => { const messages: ModelMessage[] = [ createMessage('user', 'Hello'), createMessage('assistant', 'Hi there!') ] expect(shouldTruncateMessages(messages, mockModel)).toBe(false) }) test('returns true when over limit', () => { // Create messages that exceed the token limit // mockModel (gpt-4o-mini) has 98816 max tokens const longText = 'This is a test message. '.repeat(1000) // ~6000 tokens per message const messages: ModelMessage[] = Array(20) .fill(null) .map(() => createMessage('user', longText)) // Total: ~120,000 tokens > 98,816 max tokens expect(shouldTruncateMessages(messages, mockModel)).toBe(true) }) test('handles null/undefined messages gracefully', () => { expect(shouldTruncateMessages(null as any, mockModel)).toBe(false) expect(shouldTruncateMessages(undefined as any, mockModel)).toBe(false) }) }) describe('truncateMessages', () => { test('returns empty array for empty messages', () => { expect(truncateMessages([], 1000)).toEqual([]) }) test('returns empty array for invalid maxTokens', () => { const messages = [createMessage('user', 'Hello')] expect(truncateMessages(messages, 0)).toEqual([]) expect(truncateMessages(messages, -100)).toEqual([]) }) test('returns all messages when under limit', () => { const messages: ModelMessage[] = [ createMessage('user', 'Hello'), createMessage('assistant', 'Hi!'), createMessage('user', 'How are you?'), createMessage('assistant', 'I am fine!') ] const result = truncateMessages(messages, 10000) expect(result).toEqual(messages) }) test('preserves first user message when possible', () => { const messages: ModelMessage[] = [ createMessage('user', 'First important context'), createMessage('assistant', 'Response 1'), createMessage('user', 'Question 2'), createMessage('assistant', 'Response 2'), createMessage('user', 'Question 3'), createMessage('assistant', 'Response 3') ] const result = truncateMessages(messages, 100) // Very low limit expect(result.length).toBeGreaterThan(0) expect(result[0]).toEqual(messages[0]) // First user message preserved }) test('removes assistant messages to keep user messages', () => { const messages: ModelMessage[] = [ createMessage('user', 'Question 1'), createMessage('assistant', 'Very long response '.repeat(50)), createMessage('user', 'Question 2'), createMessage('assistant', 'Another long response '.repeat(50)), createMessage('user', 'Important last question') ] const result = truncateMessages(messages, 200) const userMessages = result.filter(m => m.role === 'user') expect(userMessages.length).toBeGreaterThan(0) expect(userMessages[userMessages.length - 1].content).toBe( 'Important last question' ) }) test('removes leading assistant messages when truncating', () => { // Create messages that will force truncation const longText = 'a'.repeat(1000) // ~250 tokens each const messages: ModelMessage[] = [ createMessage('assistant', longText), createMessage('assistant', longText), createMessage('user', 'Hello'), createMessage('assistant', 'Hi'), createMessage('user', 'Last message') ] // Force truncation with low limit const result = truncateMessages(messages, 100) // After truncation, should prefer user messages expect(result.length).toBeGreaterThan(0) // The implementation removes leading non-user messages after truncation const hasUserMessage = result.some(m => m.role === 'user') expect(hasUserMessage).toBe(true) }) test('handles messages with complex content types', () => { const messages: ModelMessage[] = [ { role: 'user', content: [ { type: 'text', text: 'Hello' }, { type: 'text', text: 'World' } ] }, { role: 'assistant', content: 'Response' } ] const result = truncateMessages(messages, 1000) expect(result.length).toBeGreaterThan(0) }) test('handles undefined content gracefully', () => { const messages: ModelMessage[] = [ { role: 'user', content: '' }, { role: 'assistant', content: 'Response' } ] const result = truncateMessages(messages, 1000) expect(result).toBeDefined() expect(Array.isArray(result)).toBe(true) }) }) describe('truncation with model ID', () => { test('uses tiktoken when model ID is provided', () => { const messages: ModelMessage[] = [ createMessage('user', 'Test message for token counting') ] // With model ID - should use tiktoken const resultWithModel = truncateMessages(messages, 1000, 'gpt-4o-mini') expect(resultWithModel).toBeDefined() // Without model ID - should use fallback const resultWithoutModel = truncateMessages(messages, 1000) expect(resultWithoutModel).toBeDefined() }) }) }) ================================================ FILE: lib/utils/__tests__/domain.test.ts ================================================ import { describe, expect, it } from 'vitest' import { displayUrlName } from '../domain' describe('displayUrlName', () => { it('extracts domain name from standard URL', () => { expect(displayUrlName('https://www.google.com')).toBe('google') expect(displayUrlName('https://www.example.org')).toBe('example') expect(displayUrlName('https://www.github.io')).toBe('github') }) it('handles subdomains correctly', () => { expect(displayUrlName('https://docs.github.com')).toBe('github') expect(displayUrlName('https://api.example.org')).toBe('example') expect(displayUrlName('https://en.wikipedia.org')).toBe('wikipedia') }) it('handles URLs without subdomain', () => { expect(displayUrlName('https://example.com')).toBe('example') expect(displayUrlName('https://github.io')).toBe('github') }) it('handles localhost and simple hostnames', () => { expect(displayUrlName('http://localhost')).toBe('localhost') expect(displayUrlName('http://localhost:3000')).toBe('localhost') }) it('handles complex subdomains', () => { expect(displayUrlName('https://sub.domain.example.com')).toBe( 'domain.example' ) }) it('returns fallback for invalid URLs', () => { expect(displayUrlName('not-a-url')).toBe('source') expect(displayUrlName('')).toBe('source') expect(displayUrlName('://invalid')).toBe('source') }) it('handles URLs with paths and query parameters', () => { expect(displayUrlName('https://www.google.com/search?q=test')).toBe( 'google' ) expect(displayUrlName('https://docs.github.com/en/get-started')).toBe( 'github' ) }) it('handles different protocols', () => { expect(displayUrlName('http://www.example.com')).toBe('example') expect(displayUrlName('https://www.example.com')).toBe('example') expect(displayUrlName('ftp://ftp.example.com')).toBe('example') }) it('handles real-world news domains', () => { expect(displayUrlName('https://www.bbc.com/news')).toBe('bbc') expect(displayUrlName('https://www.cnn.com/world')).toBe('cnn') expect(displayUrlName('https://techcrunch.com/article')).toBe('techcrunch') }) it('handles stack overflow and similar domains', () => { expect(displayUrlName('https://stackoverflow.com/questions/123')).toBe( 'stackoverflow' ) expect(displayUrlName('https://meta.stackoverflow.com')).toBe( 'stackoverflow' ) }) }) ================================================ FILE: lib/utils/__tests__/model-selection.test.ts ================================================ import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ModelType } from '@/lib/types/model-type' import type { Model } from '@/lib/types/models' import type { SearchMode } from '@/lib/types/search' vi.mock('@/lib/config/model-types') vi.mock('@/lib/utils/registry') import { getModelForModeAndType } from '@/lib/config/model-types' import { DEFAULT_MODEL, selectModel } from '@/lib/utils/model-selection' import { isProviderEnabled } from '@/lib/utils/registry' const mockGetModelForModeAndType = vi.mocked(getModelForModeAndType) const mockIsProviderEnabled = vi.mocked(isProviderEnabled) type Matrix = Record<SearchMode, Partial<Record<ModelType, Model>>> const quickSpeedModel: Model = { id: 'quick-speed', name: 'Quick Speed', provider: 'Provider A', providerId: 'provider-a' } const quickQualityModel: Model = { id: 'quick-quality', name: 'Quick Quality', provider: 'Provider B', providerId: 'provider-b' } const adaptiveQualityModel: Model = { id: 'adaptive-quality', name: 'Adaptive Quality', provider: 'Provider C', providerId: 'provider-c' } const adaptiveSpeedModel: Model = { id: 'adaptive-speed', name: 'Adaptive Speed', provider: 'Provider D', providerId: 'provider-d' } let matrix: Matrix function setMatrixImplementation() { mockGetModelForModeAndType.mockImplementation( (mode: SearchMode, type: ModelType) => matrix[mode]?.[type] ) } function createCookieStore(modelType?: string): ReadonlyRequestCookies { return { get: (name: string) => { if (name === 'modelType' && modelType) { return { name, value: modelType } as any } return undefined } } as unknown as ReadonlyRequestCookies } describe('selectModel', () => { beforeEach(() => { vi.clearAllMocks() matrix = { quick: { speed: quickSpeedModel, quality: quickQualityModel }, adaptive: { speed: adaptiveSpeedModel, quality: adaptiveQualityModel } } setMatrixImplementation() mockIsProviderEnabled.mockReturnValue(true) }) it('returns the cookie-preferred type for the active mode when available', () => { const result = selectModel({ cookieStore: createCookieStore('quality'), searchMode: 'quick' }) expect(result).toEqual(quickQualityModel) }) it('falls back to speed model for the mode when cookie is absent', () => { const result = selectModel({ cookieStore: createCookieStore(), searchMode: 'adaptive' }) expect(result).toEqual(adaptiveSpeedModel) }) it('falls back to the other type within the same mode when preferred provider is disabled', () => { mockIsProviderEnabled.mockImplementation(providerId => providerId === 'provider-a' ? false : true ) const result = selectModel({ cookieStore: createCookieStore('speed'), searchMode: 'quick' }) expect(result).toEqual(quickQualityModel) }) it('falls back to the next mode in priority order when active mode has no enabled models', () => { mockIsProviderEnabled.mockImplementation(providerId => providerId === 'provider-a' || providerId === 'provider-b' ? false : true ) const result = selectModel({ cookieStore: createCookieStore('quality'), searchMode: 'quick' }) expect(result).toEqual(adaptiveQualityModel) }) it('returns DEFAULT_MODEL when no configured providers are enabled', () => { mockIsProviderEnabled.mockReturnValue(false) const result = selectModel({ cookieStore: createCookieStore(), searchMode: 'quick' }) expect(result).toEqual(DEFAULT_MODEL) }) }) ================================================ FILE: lib/utils/citation.ts ================================================ import type { SearchResultItem, SearchResults } from '@/lib/types' import type { UIMessage } from '@/lib/types/ai' import { displayUrlName } from '@/lib/utils/domain' /** * Validate if a string is a valid URL */ function isValidUrl(url: string): boolean { try { new URL(url) return true } catch { return false } } /** * Extract citation maps from a message's tool parts * Returns a map of toolCallId to citation map */ export function extractCitationMaps( message: UIMessage ): Record<string, Record<number, SearchResultItem>> { const citationMaps: Record<string, Record<number, SearchResultItem>> = {} if (!message.parts) return citationMaps message.parts.forEach((part: any) => { // Check for search tool output if ( part.type === 'tool-search' && part.state === 'output-available' && part.output && part.toolCallId ) { const searchResults = part.output as SearchResults if (searchResults.citationMap) { // Store citation map with toolCallId as key citationMaps[part.toolCallId] = searchResults.citationMap } } }) return citationMaps } /** * Extract citation maps from multiple messages * Returns a combined map of toolCallId to citation map */ export function extractCitationMapsFromMessages( messages: UIMessage[] ): Record<string, Record<number, SearchResultItem>> { const combinedCitationMaps: Record< string, Record<number, SearchResultItem> > = {} messages.forEach(message => { const messageCitationMaps = extractCitationMaps(message) // Merge citation maps from this message Object.assign(combinedCitationMaps, messageCitationMaps) }) return combinedCitationMaps } /** * Process citations in content, replacing [number](#toolCallId) with [domain](url) * Display text uses domain name instead of number (e.g., [google](url)) */ export function processCitations( content: string, citationMaps: Record<string, Record<number, SearchResultItem>> ): string { if (!citationMaps || !content || Object.keys(citationMaps).length === 0) { return content || '' } // Replace [number](#toolCallId) with [domain](actual-url) // Also handle cases with spaces: [ number ] return content.replace( /\[\s*(\d+)\s*\]\(#([^)]+)\)/g, (_match, num, toolCallId) => { const citationNum = parseInt(num, 10) // Validate citation number bounds if (isNaN(citationNum) || citationNum < 1 || citationNum > 100) { return '' // Return empty string for invalid citation numbers } // Get the citation map for this toolCallId const citationMap = citationMaps[toolCallId] if (!citationMap) { return '' // Return empty string if no citation map found } const citation = citationMap[citationNum] if (!citation || !isValidUrl(citation.url)) { return '' // Return empty string for invalid citations } // Extract domain name from URL (removes TLD and subdomain) const domainName = displayUrlName(citation.url) // Encode URI to prevent injection attacks return `[${domainName}](${encodeURI(citation.url)})` } ) } ================================================ FILE: lib/utils/context-window.ts ================================================ import { ModelMessage } from 'ai' import { getEncoding, type TiktokenEncoding } from 'js-tiktoken' import { Model } from '../types/models' interface ModelContextInfo { contextWindow: number outputTokens: number } // Model-specific context window configurations const MODEL_CONTEXT_WINDOWS: Record<string, ModelContextInfo> = { // OpenAI Models 'gpt-4.1': { contextWindow: 128000, outputTokens: 16384 }, 'gpt-4.1-mini': { contextWindow: 128000, outputTokens: 16384 }, 'gpt-4.1-nano': { contextWindow: 128000, outputTokens: 16384 }, 'gpt-4o-mini': { contextWindow: 128000, outputTokens: 16384 }, // Anthropic Models 'claude-opus-4': { contextWindow: 680000, outputTokens: 8192 }, 'claude-sonnet-4': { contextWindow: 680000, outputTokens: 8192 }, 'claude-3-7-sonnet': { contextWindow: 200000, outputTokens: 8192 }, 'claude-3-7-sonnet-20250219': { contextWindow: 200000, outputTokens: 8192 }, 'claude-3-5-haiku-20241022': { contextWindow: 200000, outputTokens: 8192 }, // Google Models 'gemini-2.5-flash': { contextWindow: 1048576, outputTokens: 65536 }, 'gemini-2.5-pro': { contextWindow: 1048576, outputTokens: 65536 }, // xAI Models 'grok-4-0709': { contextWindow: 256000, outputTokens: 8192 }, 'grok-3': { contextWindow: 131072, outputTokens: 8192 }, 'grok-3-mini': { contextWindow: 131072, outputTokens: 8192 } } // Default values for unknown models const DEFAULT_CONTEXT_WINDOW = 16384 const DEFAULT_OUTPUT_TOKENS = 4096 // Safety buffer percentage (reserved for system prompts and formatting) const SAFETY_BUFFER_RATIO = 0.1 // Cache for tiktoken encoders const encoderCache = new Map<string, any>() // Mapping of our model IDs to tiktoken encoding names // js-tiktoken supports 'cl100k_base' (for GPT-4), 'p50k_base', 'r50k_base' const MODEL_TO_ENCODING: Record<string, TiktokenEncoding> = { 'gpt-4.1': 'cl100k_base', 'gpt-4.1-mini': 'cl100k_base', 'gpt-4.1-nano': 'cl100k_base', 'gpt-4o-mini': 'cl100k_base', 'claude-opus-4': 'cl100k_base', // Use GPT-4 tokenizer as approximation for Claude 'claude-sonnet-4': 'cl100k_base', 'claude-3-7-sonnet': 'cl100k_base', 'claude-3-7-sonnet-20250219': 'cl100k_base', 'claude-3-5-haiku-20241022': 'cl100k_base', 'gemini-2.5-flash': 'cl100k_base', // Use GPT-4 tokenizer as approximation for Gemini 'gemini-2.5-pro': 'cl100k_base', 'grok-4-0709': 'cl100k_base', // Use GPT-4 tokenizer as approximation for Grok 'grok-3': 'cl100k_base', 'grok-3-mini': 'cl100k_base' } /** * Get model-specific context window information */ function getModelContextInfo(modelId: string): ModelContextInfo { // Direct lookup only return ( MODEL_CONTEXT_WINDOWS[modelId] || { contextWindow: DEFAULT_CONTEXT_WINDOW, outputTokens: DEFAULT_OUTPUT_TOKENS } ) } /** * Calculate the maximum allowed tokens for input */ export function getMaxAllowedTokens(model: Model): number { const { contextWindow, outputTokens } = getModelContextInfo(model.id) // Calculate available tokens for input let availableTokens = contextWindow - outputTokens // Apply safety buffer const safetyBuffer = Math.floor(contextWindow * SAFETY_BUFFER_RATIO) availableTokens -= safetyBuffer // Ensure minimum viable token count return Math.max(availableTokens, 1000) } /** * Extract text content from various message content types */ function extractTextContent(content: ModelMessage['content']): string { if (!content) return '' // Handle string content if (typeof content === 'string') { return content } // Handle array of parts if (Array.isArray(content)) { return content .map(part => { if ('text' in part) { return part.text } return '' }) .join(' ') } // Handle other content types return '' } /** * Get or create encoder for a model */ function getEncoder(modelId: string) { try { const encodingName: TiktokenEncoding = MODEL_TO_ENCODING[modelId] || 'cl100k_base' if (!encoderCache.has(encodingName)) { const encoder = getEncoding(encodingName) encoderCache.set(encodingName, encoder) } return encoderCache.get(encodingName) } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn( `Failed to load tokenizer for model ${modelId}, falling back to estimation`, error ) } return null } } /** * Estimate token count for a message * Uses tiktoken for accurate counting when available */ function estimateTokenCount( content: ModelMessage['content'], modelId?: string ): number { const text = extractTextContent(content) if (!text) return 0 // Try to use tiktoken for accurate counting if (modelId) { const encoder = getEncoder(modelId) if (encoder) { try { const tokens = encoder.encode(text) const tokenCount = tokens.length const overhead = 4 // Message formatting tokens return tokenCount + overhead } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn( 'Failed to encode text with tiktoken, falling back to estimation', error ) } } } } // Fallback: Rough approximation // ~4 characters per token for English, adjust for other languages const baseCount = Math.ceil(text.length / 4) const overhead = 4 // Message formatting tokens return baseCount + overhead } /** * Smart message truncation with priority for context preservation */ export function truncateMessages( messages: ModelMessage[], maxTokens: number, modelId?: string ): ModelMessage[] { // Input validation if (!messages || messages.length === 0) return [] if (maxTokens <= 0) { console.error('Invalid maxTokens value:', maxTokens) return [] } // Always try to keep the first user message (initial context) const firstUserIndex = messages.findIndex(m => m.role === 'user') const firstUserMessage = firstUserIndex >= 0 ? messages[firstUserIndex] : null // Calculate token counts for all messages const messageTokenCounts = messages.map(msg => ({ message: msg, tokens: estimateTokenCount(msg.content, modelId) })) // Calculate total tokens const totalTokens = messageTokenCounts.reduce( (sum, item) => sum + item.tokens, 0 ) // If under limit, return all messages if (totalTokens <= maxTokens) { return messages } // Strategy: Keep first user message + as many recent messages as possible const result: ModelMessage[] = [] let usedTokens = 0 // Reserve space for first user message if it exists if (firstUserMessage) { const firstUserTokens = estimateTokenCount( firstUserMessage.content, modelId ) if (firstUserTokens < maxTokens * 0.3) { // Don't let first message take more than 30% result.push(firstUserMessage) usedTokens += firstUserTokens } } // Add recent messages from the end const recentMessages: ModelMessage[] = [] for (let i = messages.length - 1; i >= 0; i--) { const { message, tokens } = messageTokenCounts[i] // Skip if this is the first user message (already added) if (firstUserMessage && i === firstUserIndex) continue if (usedTokens + tokens <= maxTokens) { recentMessages.unshift(message) usedTokens += tokens } else { // Try to at least include the last user message if we haven't if (message.role === 'user' && recentMessages.length > 0) { // Remove oldest assistant messages to make room while (recentMessages.length > 0 && usedTokens + tokens > maxTokens) { const removed = recentMessages.shift() if (removed) { usedTokens -= estimateTokenCount(removed.content, modelId) } } if (usedTokens + tokens <= maxTokens) { recentMessages.unshift(message) usedTokens += tokens } } break } } // Combine results, ensuring conversation flow if (firstUserMessage && result.length > 0) { // If we kept the first message, add recent ones result.push(...recentMessages) } else { // Otherwise, just use recent messages result.push(...recentMessages) } // Ensure the result starts with a user message while (result.length > 0 && result[0].role !== 'user') { result.shift() } return result } /** * Check if messages need truncation */ export function shouldTruncateMessages( messages: ModelMessage[], model: Model ): boolean { if (!messages || messages.length === 0) return false const maxTokens = getMaxAllowedTokens(model) const totalTokens = messages.reduce( (sum, msg) => sum + estimateTokenCount(msg.content, model.id), 0 ) return totalTokens > maxTokens } ================================================ FILE: lib/utils/cookies.ts ================================================ export function setCookie(name: string, value: string, days = 30) { if (typeof document === 'undefined') return const date = new Date() date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000) const expires = `expires=${date.toUTCString()}` document.cookie = `${name}=${value};${expires};path=/` } export function getCookie(name: string): string | null { if (typeof document === 'undefined') return null const cookies = document.cookie.split(';') for (const cookie of cookies) { const [cookieName, cookieValue] = cookie.trim().split('=') if (cookieName === name) { return cookieValue } } return null } export function deleteCookie(name: string) { if (typeof document === 'undefined') return document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/` } ================================================ FILE: lib/utils/domain.ts ================================================ /** * Extract display name from URL by removing TLD and www/subdomain * This is a pure client-safe utility function without Next.js dependencies * @param url - Full URL string * @returns Domain name without TLD (e.g., "google" from "www.google.com") * @example * displayUrlName("https://www.google.com") // "google" * displayUrlName("https://docs.github.com") // "github" * displayUrlName("https://en.wikipedia.org") // "wikipedia" */ export const displayUrlName = (url: string): string => { try { const hostname = new URL(url).hostname const parts = hostname.split('.') // For hostnames like "www.google.com" or "docs.github.com" // Extract the main domain name (second-to-last part) // parts.length > 2: ["www", "google", "com"] -> "google" // parts.length <= 2: ["localhost"] or ["example", "com"] -> "example" return parts.length > 2 ? parts.slice(1, -1).join('.') : parts[0] } catch { // Fallback for invalid URLs return 'source' } } ================================================ FILE: lib/utils/index.ts ================================================ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import { type Model } from '@/lib/types/models' // Function to generate a UUID export function generateUUID(): string { // Generate UUIDv4 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, v = c === 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) } export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } /** * Sanitizes a URL by replacing spaces with '%20' * @param url - The URL to sanitize * @returns The sanitized URL */ export function sanitizeUrl(url: string): string { return url.replace(/\s+/g, '%20') } export function createModelId(model: Model): string { return `${model.providerId}:${model.id}` } export function getDefaultModelId(models: Model[]): string { if (!models.length) { throw new Error('No models available') } return createModelId(models[0]) } ================================================ FILE: lib/utils/message-mapping.ts ================================================ import { generateId } from '@/lib/db/schema' import type { UIDataTypes, UIMessage, UIMessageMetadata, UITools } from '@/lib/types/ai' import type { DynamicToolPart } from '@/lib/types/dynamic-tools' import type { DBMessagePart, DBMessagePartSelect, ToolState } from '@/lib/types/message-persistence' // Define local types for message parts that are compatible with the AI SDK type TextUIPart = { type: 'text'; text: string; providerMetadata?: any } type ReasoningUIPart = { type: 'reasoning' text: string providerMetadata?: any } type FileUIPart = { type: 'file' mediaType: string filename?: string url: string } type SourceUrlUIPart = { type: 'source-url' sourceId: string url: string title: string } // title is required type SourceDocumentUIPart = { type: 'source-document' sourceId: string mediaType: string title: string filename: string url: string snippet: string } // all fields required type ToolCallPart = { type: 'tool-call' toolCallId: string toolName: string args: any } type ToolResultPart = { type: 'tool-result' toolCallId: string result: any isError?: boolean } type DataPart = { type: string; [key: string]: any } type UIMessagePart = | TextUIPart | ReasoningUIPart | FileUIPart | SourceUrlUIPart | SourceDocumentUIPart | ToolCallPart | ToolResultPart | DataPart // Type guards function isToolCallPart(part: any): part is ToolCallPart { return ( part.type === 'tool-call' && typeof part.toolCallId === 'string' && typeof part.toolName === 'string' && part.args !== undefined ) } function isToolResultPart(part: any): part is ToolResultPart { return ( part.type === 'tool-result' && typeof part.toolCallId === 'string' && part.result !== undefined ) } // Type for tool-specific parts with extended properties type ExtendedToolPart = { type: string toolCallId?: string state?: ToolState errorText?: string input?: any output?: any } function isExtendedToolPart(part: any): part is ExtendedToolPart { return ( typeof part === 'object' && part !== null && typeof part.type === 'string' && part.type.startsWith('tool-') ) } // Helper function to create tool part mapping function createToolPartMapping( basePart: Omit<DBMessagePart, 'type'>, part: ExtendedToolPart, toolName: string ): DBMessagePart { const inputColumn = `tool_${toolName}_input` as keyof DBMessagePart const outputColumn = `tool_${toolName}_output` as keyof DBMessagePart return { ...basePart, type: part.type, tool_toolCallId: part.toolCallId || generateId(), tool_state: part.state || ('input-available' as ToolState), tool_errorText: part.errorText, [inputColumn]: part.input, [outputColumn]: part.output } as DBMessagePart } /** * Convert UI message parts to DB format */ export function mapUIMessagePartsToDBParts( messageParts: UIMessagePart[], messageId: string ): DBMessagePart[] { const mappedParts = messageParts.map((part, index): DBMessagePart | null => { const basePart = { messageId, order: index, type: part.type } switch (part.type) { case 'text': return { ...basePart, text_text: part.text } case 'reasoning': return { ...basePart, reasoning_text: part.text, providerMetadata: part.providerMetadata } case 'file': return { ...basePart, file_mediaType: part.mediaType, file_filename: part.filename, file_url: part.url } case 'source-url': return { ...basePart, source_url_sourceId: part.sourceId, source_url_url: part.url, source_url_title: part.title } case 'source-document': return { ...basePart, source_document_sourceId: part.sourceId, source_document_mediaType: part.mediaType, source_document_title: part.title, source_document_filename: part.filename, source_document_url: part.url, source_document_snippet: part.snippet } // Tool parts case 'tool-call': // Type guard ensures part has the required properties if (!isToolCallPart(part)) { console.error('Invalid tool-call part:', part) return null } const toolName = getToolNameFromType(part.toolName) const toolInputColumn = `tool_${toolName}_input` as keyof DBMessagePart const result = { ...basePart, type: `tool-${toolName}`, tool_toolCallId: part.toolCallId, tool_state: 'input-available' as ToolState, [toolInputColumn]: part.args } as DBMessagePart // Store additional metadata for dynamic tools if (toolName === 'dynamic') { result.tool_dynamic_name = part.toolName result.tool_dynamic_type = part.toolName.startsWith('mcp__') ? 'mcp' : 'dynamic' } return result case 'tool-result': const resultToolName = getToolNameFromCallId( part.toolCallId, messageParts ) const toolOutputColumn = `tool_${resultToolName}_output` as keyof DBMessagePart const toolResult = { ...basePart, type: `tool-${resultToolName}`, tool_toolCallId: part.toolCallId, tool_state: part.isError ? 'output-error' : ('output-available' as ToolState), tool_errorText: part.isError ? String(part.result) : undefined, [toolOutputColumn]: !part.isError ? part.result : undefined } as DBMessagePart // Preserve dynamic tool metadata from the corresponding tool-call if (resultToolName === 'dynamic') { const toolCallPart = messageParts.find( p => isToolCallPart(p) && p.toolCallId === part.toolCallId ) as ToolCallPart | undefined if (toolCallPart) { toolResult.tool_dynamic_name = toolCallPart.toolName toolResult.tool_dynamic_type = toolCallPart.toolName.startsWith( 'mcp__' ) ? 'mcp' : 'dynamic' } } return toolResult // Step parts (for UI tracking) case 'step-start': // Persist step-start to maintain message structure return basePart case 'step-result': case 'step-continue': case 'step-finish': return null // These are not needed for message structure // Dynamic tool parts from AI SDK v5 case 'dynamic-tool': const dynamicPart = part as DynamicToolPart return { ...basePart, type: 'tool-dynamic', tool_toolCallId: dynamicPart.toolCallId || generateId(), tool_state: dynamicPart.state as ToolState, tool_dynamic_name: dynamicPart.toolName, tool_dynamic_type: dynamicPart.toolName.startsWith('mcp__') ? 'mcp' : 'dynamic', tool_dynamic_input: dynamicPart.input, tool_dynamic_output: dynamicPart.state === 'output-available' ? dynamicPart.output : undefined, tool_errorText: dynamicPart.state === 'output-error' ? dynamicPart.errorText : undefined } // Tool-specific parts that are not tool-call or tool-result // The following cases are tool parts with state tracking case 'tool-search': if (!isExtendedToolPart(part)) { console.error('Invalid extended tool part:', part) return null } return createToolPartMapping(basePart, part, 'search') case 'tool-fetch': if (!isExtendedToolPart(part)) { console.error('Invalid extended tool part:', part) return null } return createToolPartMapping(basePart, part, 'fetch') case 'tool-question': if (!isExtendedToolPart(part)) { console.error('Invalid extended tool part:', part) return null } return createToolPartMapping(basePart, part, 'question') case 'tool-todoWrite': if (!isExtendedToolPart(part)) { console.error('Invalid extended tool part:', part) return null } return createToolPartMapping(basePart, part, 'todoWrite') case 'tool-todoRead': if (!isExtendedToolPart(part)) { console.error('Invalid extended tool part:', part) return null } return createToolPartMapping(basePart, part, 'todoRead') // Data parts default: if (part.type.startsWith('data-')) { const dataType = part.type.substring(5) // Remove 'data-' prefix return { ...basePart, data_prefix: dataType, data_content: 'data' in part ? part.data : part, data_id: 'id' in part ? part.id : undefined } } // Unknown part type - store as data return { ...basePart, data_prefix: part.type, data_content: part } } }) // Filter out null values and re-index return mappedParts .filter((part): part is DBMessagePart => part !== null) .map((part, index) => ({ ...part, order: index })) } /** * Convert DB message parts to UI format */ export function mapDBPartToUIMessagePart( part: DBMessagePartSelect ): UIMessagePart { switch (part.type) { case 'text': return { type: 'text', text: part.text_text || '' } case 'reasoning': return { type: 'reasoning', text: part.reasoning_text || '', providerMetadata: part.providerMetadata } case 'file': return { type: 'file', mediaType: part.file_mediaType || '', filename: part.file_filename || '', url: part.file_url || '' } case 'source-url': return { type: 'source-url', sourceId: part.source_url_sourceId || '', url: part.source_url_url || '', title: part.source_url_title || '' } case 'source-document': return { type: 'source-document', sourceId: part.source_document_sourceId || '', mediaType: part.source_document_mediaType || '', title: part.source_document_title || '', filename: part.source_document_filename || '', url: part.source_document_url || '', snippet: part.source_document_snippet || '' } default: // Tool parts if (part.type.startsWith('tool-')) { const toolName = part.type.substring(5) // Remove 'tool-' prefix const inputColumn = `tool_${toolName}_input` as keyof DBMessagePartSelect const outputColumn = `tool_${toolName}_output` as keyof DBMessagePartSelect // Special handling for dynamic tools if (toolName === 'dynamic') { return { type: 'dynamic-tool', toolCallId: part.tool_toolCallId || '', toolName: part.tool_dynamic_name || '', state: part.tool_state as any, // Maps directly to AI SDK states input: part.tool_dynamic_input, output: part.tool_dynamic_output, errorText: part.tool_errorText } } // Special handling for tool parts that maintain their type if (toolName === 'search') { if (!part.tool_state) { throw new Error(`tool_state is undefined for ${toolName}`) } switch (part.tool_state) { case 'input-streaming': return { type: 'tool-search', state: 'input-streaming', toolCallId: part.tool_toolCallId || '', input: part.tool_search_input! } case 'input-available': return { type: 'tool-search', state: 'input-available', toolCallId: part.tool_toolCallId || '', input: part.tool_search_input! } case 'output-available': return { type: 'tool-search', state: 'output-available', toolCallId: part.tool_toolCallId || '', input: part.tool_search_input!, output: part.tool_search_output! } case 'output-error': return { type: 'tool-search', state: 'output-error', toolCallId: part.tool_toolCallId || '', input: part.tool_search_input!, errorText: part.tool_errorText! } default: throw new Error(`Unknown tool state: ${part.tool_state}`) } } if (toolName === 'fetch') { if (!part.tool_state) { throw new Error(`tool_state is undefined for ${toolName}`) } switch (part.tool_state) { case 'input-streaming': return { type: 'tool-fetch', state: 'input-streaming', toolCallId: part.tool_toolCallId || '', input: part.tool_fetch_input! } case 'input-available': return { type: 'tool-fetch', state: 'input-available', toolCallId: part.tool_toolCallId || '', input: part.tool_fetch_input! } case 'output-available': return { type: 'tool-fetch', state: 'output-available', toolCallId: part.tool_toolCallId || '', input: part.tool_fetch_input!, output: part.tool_fetch_output! } case 'output-error': return { type: 'tool-fetch', state: 'output-error', toolCallId: part.tool_toolCallId || '', input: part.tool_fetch_input!, errorText: part.tool_errorText! } default: throw new Error(`Unknown tool state: ${part.tool_state}`) } } if (toolName === 'question') { if (!part.tool_state) { throw new Error(`tool_state is undefined for ${toolName}`) } switch (part.tool_state) { case 'input-streaming': return { type: 'tool-question', state: 'input-streaming', toolCallId: part.tool_toolCallId || '', input: part.tool_question_input! } case 'input-available': return { type: 'tool-question', state: 'input-available', toolCallId: part.tool_toolCallId || '', input: part.tool_question_input! } case 'output-available': return { type: 'tool-question', state: 'output-available', toolCallId: part.tool_toolCallId || '', input: part.tool_question_input!, output: part.tool_question_output! } case 'output-error': return { type: 'tool-question', state: 'output-error', toolCallId: part.tool_toolCallId || '', input: part.tool_question_input!, errorText: part.tool_errorText! } default: throw new Error(`Unknown tool state: ${part.tool_state}`) } } if (toolName === 'todoWrite') { if (!part.tool_state) { throw new Error(`tool_state is undefined for ${toolName}`) } switch (part.tool_state) { case 'input-streaming': return { type: 'tool-todoWrite', state: 'input-streaming', toolCallId: part.tool_toolCallId || '', input: part.tool_todoWrite_input! } case 'input-available': return { type: 'tool-todoWrite', state: 'input-available', toolCallId: part.tool_toolCallId || '', input: part.tool_todoWrite_input! } case 'output-available': return { type: 'tool-todoWrite', state: 'output-available', toolCallId: part.tool_toolCallId || '', input: part.tool_todoWrite_input!, output: part.tool_todoWrite_output! } case 'output-error': return { type: 'tool-todoWrite', state: 'output-error', toolCallId: part.tool_toolCallId || '', input: part.tool_todoWrite_input!, errorText: part.tool_errorText! } default: throw new Error(`Unknown tool state: ${part.tool_state}`) } } if (toolName === 'todoRead') { if (!part.tool_state) { throw new Error(`tool_state is undefined for ${toolName}`) } switch (part.tool_state) { case 'input-streaming': return { type: 'tool-todoRead', state: 'input-streaming', toolCallId: part.tool_toolCallId || '', input: part.tool_todoRead_input! } case 'input-available': return { type: 'tool-todoRead', state: 'input-available', toolCallId: part.tool_toolCallId || '', input: part.tool_todoRead_input! } case 'output-available': return { type: 'tool-todoRead', state: 'output-available', toolCallId: part.tool_toolCallId || '', input: part.tool_todoRead_input!, output: part.tool_todoRead_output! } case 'output-error': return { type: 'tool-todoRead', state: 'output-error', toolCallId: part.tool_toolCallId || '', input: part.tool_todoRead_input!, errorText: part.tool_errorText! } default: throw new Error(`Unknown tool state: ${part.tool_state}`) } } // Standard tool-call/tool-result pattern if ( part.tool_state === 'input-available' || part.tool_state === 'input-streaming' ) { // For dynamic tools, use the stored original name const originalToolName = toolName === 'dynamic' && part.tool_dynamic_name ? part.tool_dynamic_name : getOriginalToolName(toolName) return { type: 'tool-call', toolCallId: part.tool_toolCallId || '', toolName: originalToolName, args: part[inputColumn] as any } } else { // output-available or output-error return { type: 'tool-result', toolCallId: part.tool_toolCallId || '', isError: part.tool_state === 'output-error', result: part.tool_state === 'output-error' ? part.tool_errorText : part[outputColumn] } } } // Step parts if (part.type === 'step-start') { return { type: 'step-start' } } // Data parts if (part.data_prefix) { return { type: `data-${part.data_prefix}`, data: part.data_content, ...(part.data_id ? { id: part.data_id } : {}) } } // Fallback - should not happen throw new Error(`Unknown part type: ${part.type}`) } } /** * Normalize tool name (from tool-call's toolName) */ function getToolNameFromType(toolName: string): string { // Map original tool names to DB column names const toolNameMap: Record<string, string> = { search: 'search', fetch: 'fetch', askQuestion: 'question', question: 'question', todoWrite: 'todoWrite', todoRead: 'todoRead' } // For dynamic tools (MCP and others) if (toolName.startsWith('mcp__') || toolName.startsWith('dynamic__')) { return 'dynamic' } return toolNameMap[toolName] || toolName } /** * Get tool name from tool-result */ function getToolNameFromCallId( toolCallId: string, allParts: UIMessagePart[] ): string { // Find tool-call part with the same toolCallId const toolCallPart = allParts.find( part => part.type === 'tool-call' && part.toolCallId === toolCallId ) as any if (toolCallPart) { return getToolNameFromType(toolCallPart.toolName) } // Fallback - should not happen return 'unknown' } /** * Convert DB column name back to original tool name */ function getOriginalToolName(dbToolName: string): string { const reverseMap: Record<string, string> = { search: 'search', fetch: 'fetch', question: 'askQuestion', todoWrite: 'todoWrite', todoRead: 'todoRead', dynamic: 'dynamic' // For dynamic tools, the actual tool name is stored separately } return reverseMap[dbToolName] || dbToolName } /** * Convert UI message to DB message (excluding parts) */ export function mapUIMessageToDBMessage( message: UIMessage & { id: string; chatId: string } ): { id: string chatId: string role: string metadata?: UIMessageMetadata | null } { return { id: message.id, chatId: message.chatId, role: message.role, metadata: message.metadata || null } } /** * Build UI message from DB message and parts */ export function buildUIMessageFromDB( dbMessage: { id: string role: string metadata?: UIMessageMetadata | null createdAt?: Date | string }, dbParts: DBMessagePartSelect[] ): UIMessage { // Merge metadata from DB with createdAt const metadata: UIMessageMetadata = { ...(dbMessage.metadata || {}), ...(dbMessage.createdAt && { createdAt: dbMessage.createdAt instanceof Date ? dbMessage.createdAt : new Date(dbMessage.createdAt) }) } return { id: dbMessage.id, role: dbMessage.role as 'user' | 'assistant', parts: dbParts.map(mapDBPartToUIMessagePart) as UIMessage['parts'], metadata: Object.keys(metadata).length > 0 ? metadata : undefined } } ================================================ FILE: lib/utils/message-utils.ts ================================================ import { ModelMessage, UIMessage } from 'ai' import { type Message as DBMessage } from '@/lib/db/schema' // Interface matching the expected DB message input format interface DatabaseMessageInput { role: DBMessage['role'] parts: any // Using 'any' here as we don't know the exact structure expected by the database } /** * Converts a single message from AI SDK to a database-compatible message format * @param message - Message from AI SDK * @returns Database-compatible message object */ export function convertMessageForDB( message: ModelMessage ): DatabaseMessageInput { // Handle case where content might be a string, array, or null let parts: any if (message.content === null || message.content === undefined) { parts = [] } else if (typeof message.content === 'string') { parts = [{ text: message.content }] } else if (Array.isArray(message.content)) { // For array content (common in assistant messages with tool calls) // Extract text parts and join them const textParts = message.content .filter(part => part.type === 'text') .map(part => ({ text: part.text })) if (textParts.length > 0) { parts = textParts } else { // If no text parts, use the first part's content or stringify the whole content parts = [{ text: JSON.stringify(message.content) }] } } else { // Fall back to JSON string for other content types parts = [{ text: JSON.stringify(message.content) }] } return { role: message.role, parts: parts } } /** * Converts an array of messages from AI SDK to database-compatible message format * @param messages - Array of messages from AI SDK * @returns Array of database-compatible message objects */ export function convertMessagesForDB( messages: ModelMessage[] ): DatabaseMessageInput[] { return messages.map(convertMessageForDB) } /** * Extract the first text content from a message for use as a title * @param message - Message from AI SDK * @param maxLength - Maximum title length to extract * @returns Extracted title string, truncated to maxLength */ export function extractTitleFromMessage( message: ModelMessage, maxLength = 100 ): string { if (!message.content) return 'New Chat' if (typeof message.content === 'string') { return message.content.substring(0, maxLength) } // For array content, try to find text parts if (Array.isArray(message.content)) { const textPart = message.content.find(part => part.type === 'text') if (textPart && 'text' in textPart) { return textPart.text.substring(0, maxLength) } } return 'New Chat' } /** * Extracts text content from UIMessage parts. * @param parts Array of message parts to extract text from. * @returns Concatenated text content or empty string if no text content is found, * if 'message' or 'message.parts' is undefined, or if 'parts' is empty or contains no text parts. */ export function getTextFromParts(parts?: UIMessage['parts']): string { return ( parts ?.filter(part => part.type === 'text') .map(part => part.text) .join(' ') ?? '' ) } /** * Merges two UIMessage objects by combining their parts * @param primaryMessage The main message (properties from this will be preserved) * @param secondaryMessage The message whose parts will be merged into the primary message * @returns A new UIMessage with combined parts */ export function mergeUIMessages( primaryMessage: UIMessage, secondaryMessage: UIMessage ): UIMessage { return { ...primaryMessage, parts: [...primaryMessage.parts, ...secondaryMessage.parts] } } /** * Checks if a UIMessage contains tool calls * @param message The message to check for tool calls * @returns true if the message contains tool calls, false otherwise */ export function hasToolCalls(message: UIMessage | null): boolean { if (!message || !message.parts) return false return message.parts.some( part => part.type && (part.type.startsWith('tool-') || part.type === 'tool-call') ) } ================================================ FILE: lib/utils/model-selection.ts ================================================ import { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies' import { getModelForModeAndType } from '@/lib/config/model-types' import { ModelType } from '@/lib/types/model-type' import { Model } from '@/lib/types/models' import { SearchMode } from '@/lib/types/search' import { isProviderEnabled } from '@/lib/utils/registry' const DEFAULT_MODEL: Model = { id: 'gpt-5-mini', name: 'GPT-5 mini', provider: 'OpenAI', providerId: 'openai', providerOptions: { openai: { reasoningEffort: 'low', reasoningSummary: 'auto' } } } const VALID_MODEL_TYPES: ModelType[] = ['speed', 'quality'] const MODE_FALLBACK_ORDER: SearchMode[] = ['quick', 'adaptive'] interface ModelSelectionParams { cookieStore: ReadonlyRequestCookies searchMode?: SearchMode } function resolveModelForModeAndType( mode: SearchMode, type: ModelType ): Model | undefined { try { const model = getModelForModeAndType(mode, type) if (!model) { return undefined } if (!isProviderEnabled(model.providerId)) { console.warn( `[ModelSelection] Provider "${model.providerId}" is not enabled for mode "${mode}" and model type "${type}"` ) return undefined } return model } catch (error) { console.error( `[ModelSelection] Failed to load model configuration for mode "${mode}" and type "${type}":`, error ) return undefined } } /** * Determines which model to use based on the model type preference. * * Priority order: * 1. If model type is in cookie -> use corresponding model from config (when enabled) * 2. Otherwise -> use default ordering (speed → quality) for the active mode * 3. If the active mode has no enabled models, try remaining modes * 4. If config loading fails or providers are unavailable -> use DEFAULT_MODEL as fallback */ export function selectModel({ cookieStore, searchMode }: ModelSelectionParams): Model { const modelTypeCookie = cookieStore.get('modelType')?.value as | ModelType | undefined const requestedMode = searchMode && MODE_FALLBACK_ORDER.includes(searchMode) ? searchMode : 'quick' const typePreferenceOrder: ModelType[] = [] if ( modelTypeCookie && VALID_MODEL_TYPES.includes(modelTypeCookie) && !typePreferenceOrder.includes(modelTypeCookie) ) { typePreferenceOrder.push(modelTypeCookie) } for (const knownType of VALID_MODEL_TYPES) { if (!typePreferenceOrder.includes(knownType)) { typePreferenceOrder.push(knownType) } } const modePreferenceOrder: SearchMode[] = Array.from( new Set<SearchMode>([requestedMode, ...MODE_FALLBACK_ORDER]) ) for (const candidateMode of modePreferenceOrder) { for (const candidateType of typePreferenceOrder) { const model = resolveModelForModeAndType(candidateMode, candidateType) if (model) { return model } } } if (!isProviderEnabled(DEFAULT_MODEL.providerId)) { console.warn( `[ModelSelection] Default model provider "${DEFAULT_MODEL.providerId}" is not enabled. Returning default model configuration.` ) } return DEFAULT_MODEL } export { DEFAULT_MODEL } ================================================ FILE: lib/utils/perf-logging.ts ================================================ // Performance logging utilities const isPerfLoggingEnabled = process.env.ENABLE_PERF_LOGGING === 'true' export function perfLog(message: string) { if (isPerfLoggingEnabled) { console.log(`[PERF] ${message}`) } } export function perfTime(label: string, startTime: number) { if (isPerfLoggingEnabled) { console.log( `[PERF] ${label}: ${(performance.now() - startTime).toFixed(2)}ms` ) } } ================================================ FILE: lib/utils/perf-tracking.ts ================================================ /** * Performance tracking utilities for DEVELOPMENT ONLY * * WARNING: These global counters are not thread-safe and should only be used * for debugging in development environments with ENABLE_PERF_LOGGING=true. * * In production, these counters are not used as performance logging is disabled. * The global state will cause issues with concurrent requests, but this is * acceptable for local development debugging. * * DO NOT use these counters for any production logic or metrics. */ let authCallCount = 0 let dbOperationCount = 0 export function resetAuthCallCount() { authCallCount = 0 } export function incrementAuthCallCount() { authCallCount++ return authCallCount } export function resetDbOperationCount() { dbOperationCount = 0 } export function incrementDbOperationCount() { dbOperationCount++ return dbOperationCount } export function resetAllCounters() { authCallCount = 0 dbOperationCount = 0 } ================================================ FILE: lib/utils/registry.ts ================================================ import { anthropic } from '@ai-sdk/anthropic' import { createGateway } from '@ai-sdk/gateway' import { google } from '@ai-sdk/google' import { createOpenAI, openai } from '@ai-sdk/openai' import { createProviderRegistry, LanguageModel } from 'ai' import { createOllama } from 'ollama-ai-provider-v2' // Build providers object conditionally const providers: Record<string, any> = { openai, anthropic, google, 'openai-compatible': createOpenAI({ apiKey: process.env.OPENAI_COMPATIBLE_API_KEY, baseURL: process.env.OPENAI_COMPATIBLE_API_BASE_URL }), gateway: createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY }) } // Only add Ollama if OLLAMA_BASE_URL is configured if (process.env.OLLAMA_BASE_URL) { providers.ollama = createOllama({ baseURL: `${process.env.OLLAMA_BASE_URL}/api` }) } export const registry = createProviderRegistry(providers) export function getModel(model: string): LanguageModel { return registry.languageModel( model as Parameters<typeof registry.languageModel>[0] ) } export function isProviderEnabled(providerId: string): boolean { switch (providerId) { case 'openai': return !!process.env.OPENAI_API_KEY case 'anthropic': return !!process.env.ANTHROPIC_API_KEY case 'google': return !!process.env.GOOGLE_GENERATIVE_AI_API_KEY case 'openai-compatible': return ( !!process.env.OPENAI_COMPATIBLE_API_KEY && !!process.env.OPENAI_COMPATIBLE_API_BASE_URL ) case 'gateway': return !!process.env.AI_GATEWAY_API_KEY case 'ollama': return !!process.env.OLLAMA_BASE_URL default: return false } } ================================================ FILE: lib/utils/retry.ts ================================================ // Exponential backoff retry utility export interface RetryOptions { maxRetries?: number initialDelayMs?: number maxDelayMs?: number backoffMultiplier?: number onRetry?: (error: any, attempt: number) => void } export async function retryWithBackoff<T>( fn: () => Promise<T>, options: RetryOptions = {} ): Promise<T> { const { maxRetries = 3, initialDelayMs = 100, maxDelayMs = 5000, backoffMultiplier = 2, onRetry } = options let lastError: any for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn() } catch (error) { lastError = error if (attempt === maxRetries) { throw error } // Calculate delay with exponential backoff const delay = Math.min( initialDelayMs * Math.pow(backoffMultiplier, attempt), maxDelayMs ) if (onRetry) { onRetry(error, attempt + 1) } // Wait before retrying await new Promise(resolve => setTimeout(resolve, delay)) } } throw lastError } // Specialized retry for database operations export async function retryDatabaseOperation<T>( operation: () => Promise<T>, operationName: string ): Promise<T> { return retryWithBackoff(operation, { maxRetries: 2, initialDelayMs: 200, maxDelayMs: 2000, onRetry: (error, attempt) => { console.log( `Retrying ${operationName} (attempt ${attempt}):`, error.message ) } }) } ================================================ FILE: lib/utils/search-config.ts ================================================ /** * Search provider configuration utilities * Provides environment-aware descriptions and guidance for search tools */ /** * Checks if a dedicated "general" search provider is available * Currently checks for Brave Search, but can be extended for other providers */ export function isGeneralSearchProviderAvailable(): boolean { return !!process.env.BRAVE_SEARCH_API_KEY } /** * Gets the name of the current general search provider */ export function getGeneralSearchProviderName(): string { if (process.env.BRAVE_SEARCH_API_KEY) { return 'Brave Search' } return 'primary provider' } /** * Checks if the general search provider supports multimedia content types */ export function supportsMultimediaContentTypes(): boolean { // Currently only Brave supports video/image content_types return !!process.env.BRAVE_SEARCH_API_KEY } /** * Gets the appropriate search type description based on available providers */ export function getSearchTypeDescription(): string { const hasGeneralProvider = isGeneralSearchProviderAvailable() const providerName = getGeneralSearchProviderName() if (hasGeneralProvider) { return `Search type: general for ${providerName} (supports video/image with content_types, basic results may need fetch for details), optimized for AI-focused providers with content snippets (Tavily/Exa/SearXNG)` } else { return 'Search type: general and optimized both use the primary AI-focused provider (Tavily/Exa/SearXNG) with content snippets. Note: video/image content_types require a dedicated general search provider (not configured)' } } /** * Gets the tool description based on available providers */ export function getSearchToolDescription(): string { const supportsMultimedia = supportsMultimediaContentTypes() if (supportsMultimedia) { return 'Search the web for information. For YouTube/video content, use type="general" with content_types:["video"] for optimal visual presentation with thumbnails.' } else { return 'Search the web for information using AI-focused providers. Note: Video/image searches with content_types require a dedicated general search provider (not configured). Use type="optimized" for best results with available providers.' } } /** * Gets content types guidance for agent prompts */ export function getContentTypesGuidance(): string { const hasGeneralProvider = isGeneralSearchProviderAvailable() const providerName = getGeneralSearchProviderName() const supportsMultimedia = supportsMultimediaContentTypes() if (hasGeneralProvider && supportsMultimedia) { return `- **type="general" (for time-sensitive or specific content):** - Uses ${providerName} for enhanced multimedia support - Returns search results without deep content extraction - Best for: - Today's news, current events, recent updates - Videos: content_types: ['video'] or ['web', 'video'] - Images: content_types: ['image'] or ['web', 'image'] - When you need the LATEST information where recency matters - Pattern: type="general" search → identify sources → fetch for content` } else { return `- **type="general" and type="optimized":** - Both use the primary AI-focused provider (Tavily/Exa/SearXNG) - Returns search results with content snippets - Note: Video/image content_types are not supported (requires dedicated general search provider) - Best for: Research questions, fact-finding, explanatory queries - Use type="optimized" for consistent behavior` } } /** * Gets the search strategy guidance for planning mode */ export function getSearchStrategyGuidance(): string { const hasGeneralProvider = isGeneralSearchProviderAvailable() const supportsMultimedia = supportsMultimediaContentTypes() if (hasGeneralProvider && supportsMultimedia) { return `Search strategy: - Use type="optimized" for most research queries (provides content snippets) - Use type="general" for time-sensitive info, videos, or images (requires fetch) - ALWAYS follow type="general" searches with fetch tool for content - For comprehensive research: multiple searches + selective fetching` } else { return `Search strategy: - Use type="optimized" for all queries (provides content snippets from primary provider) - type="general" will behave the same as "optimized" (dedicated general search provider not available) - Fetch tool can be used optionally for deeper content analysis - For comprehensive research: multiple searches + selective fetching` } } /** * Gets the appropriate search provider type for "general" searches * Returns 'brave' if available, otherwise null to indicate fallback */ export function getGeneralSearchProviderType(): 'brave' | null { if (process.env.BRAVE_SEARCH_API_KEY) { return 'brave' } return null } ================================================ FILE: lib/utils/telemetry.ts ================================================ /** * Check if Langfuse tracing is enabled * Default: false */ export function isTracingEnabled(): boolean { return process.env.ENABLE_LANGFUSE_TRACING === 'true' } ================================================ FILE: lib/utils/url.ts ================================================ import { headers } from 'next/headers' /** * Helper function to get base URL from headers * Extracts URL information from Next.js request headers */ export async function getBaseUrlFromHeaders(): Promise<URL> { const headersList = await headers() const baseUrl = headersList.get('x-base-url') const url = headersList.get('x-url') const host = headersList.get('x-host') const protocol = headersList.get('x-protocol') || 'http:' try { // Try to use the pre-constructed base URL if available if (baseUrl) { return new URL(baseUrl) } else if (url) { return new URL(url) } else if (host) { const constructedUrl = `${protocol}${ protocol.endsWith(':') ? '//' : '://' }${host}` return new URL(constructedUrl) } else { return new URL('http://localhost:3000') } } catch (urlError) { // Fallback to default URL if any error occurs during URL construction return new URL('http://localhost:3000') } } /** * Resolves the base URL using environment variables or headers * Centralizes the base URL resolution logic used across the application * @returns A URL object representing the base URL */ export async function getBaseUrl(): Promise<URL> { // Check for environment variables first const baseUrlEnv = process.env.NEXT_PUBLIC_BASE_URL || process.env.BASE_URL if (baseUrlEnv) { try { const baseUrlObj = new URL(baseUrlEnv) console.log('Using BASE_URL environment variable:', baseUrlEnv) return baseUrlObj } catch (error) { console.warn( 'Invalid BASE_URL environment variable, falling back to headers' ) // Fall back to headers if the environment variable is invalid } } // If no valid environment variable is available, use headers return await getBaseUrlFromHeaders() } /** * Gets the base URL as a string * Convenience wrapper around getBaseUrl that returns a string * @returns A string representation of the base URL */ export async function getBaseUrlString(): Promise<string> { const baseUrlObj = await getBaseUrl() return baseUrlObj.toString() } ================================================ FILE: next.config.mjs ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'i.ytimg.com', port: '', pathname: '/vi/**' }, { protocol: 'https', hostname: 'lh3.googleusercontent.com', port: '', pathname: '/a/**' // Google user content often follows this pattern }, { protocol: 'https', hostname: 'imgs.search.brave.com', port: '', pathname: '/**' // Brave search cached images }, { protocol: 'https', hostname: 'www.google.com', port: '', pathname: '/s2/favicons/**' // Google Favicon API } ] } } export default nextConfig ================================================ FILE: package.json ================================================ { "name": "morphic", "version": "0.1.0", "private": true, "license": "Apache-2.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", "migrate": "bun run lib/db/migrate.ts", "test": "NODE_ENV=test vitest run", "test:watch": "vitest --watch", "chat": "bun run scripts/chat-cli.ts" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.54", "@ai-sdk/gateway": "^3.0.63", "@ai-sdk/google": "^3.0.37", "@ai-sdk/openai": "^3.0.39", "@ai-sdk/react": "^3.0.113", "@aws-sdk/client-s3": "^3.850.0", "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/instrumentation": "0.57.2", "@opentelemetry/sdk-logs": "0.57.2", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-popover": "^1.1.5", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-tooltip": "^1.2.4", "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/auth-ui-react": "^0.4.7", "@supabase/auth-ui-shared": "^0.1.8", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4", "@tailwindcss/typography": "^0.5.12", "@upstash/redis": "^1.35.5", "@vercel/analytics": "^1.5.0", "@vercel/otel": "^1.13.0", "ai": "^6.0.111", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "cmdk": "1.0.0", "dotenv": "^17.2.1", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.3", "embla-carousel-react": "^8.0.0", "exa-js": "^1.0.12", "js-tiktoken": "^1.0.20", "jsdom": "^26.1.0", "katex": "^0.16.10", "langfuse-vercel": "^3.38.4", "lucide-react": "^0.507.0", "next": "^16.1.6", "next-themes": "^0.3.0", "node-html-parser": "^6.1.13", "ollama-ai-provider-v2": "^1.5.0", "postgres": "^3.4.5", "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.0.1", "react-textarea-autosize": "^8.5.3", "redis": "^4.7.0", "rehype-external-links": "^3.0.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sonner": "^1.4.41", "streamdown": "^1.2.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", "zod": "^4.0.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/jsdom": "^21.1.7", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "@vitejs/plugin-react": "^4.4.1", "eslint": "^8", "eslint-config-next": "^14.2.33", "eslint-plugin-simple-import-sort": "^12.1.1", "postcss": "^8", "prettier": "^3.6.2", "tailwindcss": "^4.1.11", "typescript": "^5", "vitest": "^3.1.3" }, "engines": { "bun": "1.2.12" } } ================================================ FILE: postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { '@tailwindcss/postcss': {} } } export default config ================================================ FILE: prettier.config.js ================================================ /** @type {import('prettier').Config} */ module.exports = { endOfLine: 'lf', semi: false, useTabs: false, singleQuote: true, arrowParens: 'avoid', tabWidth: 2, trailingComma: 'none' } ================================================ FILE: proxy.ts ================================================ import { type NextRequest, NextResponse } from 'next/server' import { updateSession } from '@/lib/supabase/middleware' export async function proxy(request: NextRequest) { // Get the protocol from X-Forwarded-Proto header or request protocol const protocol = request.headers.get('x-forwarded-proto') || request.nextUrl.protocol // Get the host from X-Forwarded-Host header or request host const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || '' // Construct the base URL - ensure protocol has :// format const baseUrl = `${protocol}${protocol.endsWith(':') ? '//' : '://'}${host}` // Create a response let response: NextResponse // Handle Supabase session if configured const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY if (supabaseUrl && supabaseAnonKey) { response = await updateSession(request) } else { // If Supabase is not configured, just pass the request through response = NextResponse.next({ request }) } // Add request information to response headers response.headers.set('x-url', request.url) response.headers.set('x-host', host) response.headers.set('x-protocol', protocol) response.headers.set('x-base-url', baseUrl) return response } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)' ] } ================================================ FILE: scripts/README.md ================================================ # Scripts This directory contains utility scripts for testing and development. ## chat-cli.ts A command-line interface for testing the chat API without a browser client. This script allows you to interact with the chat API directly, making it easier to debug server-side issues and test API functionality. ### Features - Send messages to the chat API via command line - Real-time Server-Sent Events (SSE) streaming output - Support for model types (speed/quality) with automatic model selection - Configurable search modes (quick/adaptive) or disabled - Chat session continuity - Message regeneration support - Secure authentication via environment variables - URL validation for security (localhost only) ### Setup 1. **Add authentication to `.env.local`**: ```env MORPHIC_COOKIES="your-cookie-string-here" ``` ### Usage ```bash # Using npm script (recommended) bun chat -m "Hello, how are you?" # Direct usage bun scripts/chat-cli.ts -m "Hello, how are you?" # Disable search mode bun chat -m "Tell me a joke" --no-search # Use quality model type for better responses bun chat -m "Explain quantum computing" --model-type quality # Use adaptive search mode for complex queries bun chat -m "Research the latest AI developments" --search-mode adaptive # Continue an existing chat bun chat -c "chat_123" -m "Tell me more" # Regenerate the last assistant message bun chat -c "chat_123" -t regenerate --message-id "msg_456" # Show help bun chat --help ``` ### Options - `-m, --message <text>` - Message to send (default: "Hello, how are you?") - `-u, --url <url>` - API URL (default: http://localhost:3000/api/chat, localhost only) - `-c, --chat-id <id>` - Chat ID for session continuity (default: auto-generated) - `-s, --search` - Enable search mode with adaptive strategy (default) - `--no-search` - Disable search mode - `--search-mode <type>` - Search strategy: `quick` or `adaptive` - `--model-type <type>` - Model type: `speed` (default) or `quality` - `-t, --trigger <type>` - Trigger type: `submit` (default) or `regenerate` - `--message-id <id>` - Message ID (required for regenerate trigger) - `-h, --help` - Show help message ### Output Format The script displays: - 🚀 Request details - 🤖 Model type (speed/quality) - 🔍 Search mode status (quick/adaptive/disabled) - 💬 Chat ID for reference - Real-time AI responses with proper formatting - 🔧 Tool usage (when search mode is enabled) - ✅ Completion status ### Model Types - **speed**: Fast responses using optimized models (default) - **quality**: Higher quality responses using advanced models ### Search Modes - **quick**: Fast search with basic results - **adaptive**: Intelligent search strategy based on query type, with enhanced support for complex queries (default) - **disabled**: No search functionality (`--no-search`) ### Advanced Usage #### Message Regeneration You can regenerate the last assistant message in a conversation: ```bash # First, send a message and note the chat ID and message ID from the output bun chat -m "Tell me about AI" # Then regenerate the assistant's response bun chat -c "chat_123" -t regenerate --message-id "msg_456" # Or edit the user message and regenerate bun chat -c "chat_123" -t regenerate --message-id "msg_456" -m "Tell me about machine learning instead" ``` ### Security Features - **Authentication**: Uses environment variables only (no file-based auth) - **URL Validation**: Only allows localhost and local network URLs - **No Sensitive Logging**: Cookies are never displayed in logs - **Input Sanitization**: Message length limited to 10,000 characters ### Requirements - Bun runtime - Local development server running (`bun dev`) - Valid authentication cookies in `.env.local` ### Troubleshooting #### Authentication Errors If you encounter "User not authenticated" errors: 1. Ensure you're logged into Morphic in your browser 2. Get fresh cookies from DevTools 3. Update `MORPHIC_COOKIES` in `.env.local` 4. Cookies expire after ~1 hour, so refresh them if needed #### API Errors If you encounter "Selected provider is not enabled" errors: 1. Check that the model type is correctly configured in your system 2. Verify the development server supports the requested model type 3. Try switching between `--model-type speed` and `--model-type quality` #### General Issues - Check the development server is running: `bun dev` - Verify `.env.local` exists and contains `MORPHIC_COOKIES` - Use `DEBUG=1` prefix for verbose output - Ensure the API URL is accessible (default: `http://localhost:3000/api/chat`) #### Command Examples for Testing ```bash # Test basic functionality bun chat -m "Hello, test message" --no-search # Test with quality model and adaptive search bun chat -m "Complex analysis task" --model-type quality --search-mode adaptive # Debug mode DEBUG=1 bun chat -m "Debug test" --model-type speed ``` ================================================ FILE: scripts/chat-cli.ts ================================================ #!/usr/bin/env tsx import { config as dotenvConfig } from 'dotenv' import { Readable } from 'stream' import type { ReadableStream as NodeReadableStream } from 'stream/web' // Load environment variables from .env.local dotenvConfig({ path: '.env.local' }) // Constants const DEFAULT_MESSAGE = 'Hello, how are you?' interface ChatApiConfig { apiUrl: string message: string chatId?: string modelType?: 'speed' | 'quality' searchMode?: 'quick' | 'adaptive' | boolean trigger?: 'submit-message' | 'regenerate-message' messageId?: string } interface UIMessage { id: string role: 'user' | 'assistant' | 'system' content?: string parts: Array<{ type: string text?: string [key: string]: any }> createdAt: Date } interface ChatPayload { chatId: string trigger: 'submit-message' | 'regenerate-message' message?: UIMessage messageId?: string isNewChat?: boolean } class ChatApiTester { private config: ChatApiConfig private validateUrl(url?: string): string | undefined { if (!url) return undefined try { const parsed = new URL(url) // Only allow localhost and local network if ( parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname.startsWith('192.168.') || parsed.hostname.startsWith('10.0.') || parsed.hostname.startsWith('172.16.') || parsed.hostname.startsWith('172.17.') || parsed.hostname.startsWith('172.18.') || parsed.hostname.startsWith('172.19.') || parsed.hostname.startsWith('172.20.') || parsed.hostname.startsWith('172.21.') || parsed.hostname.startsWith('172.22.') || parsed.hostname.startsWith('172.23.') || parsed.hostname.startsWith('172.24.') || parsed.hostname.startsWith('172.25.') || parsed.hostname.startsWith('172.26.') || parsed.hostname.startsWith('172.27.') || parsed.hostname.startsWith('172.28.') || parsed.hostname.startsWith('172.29.') || parsed.hostname.startsWith('172.30.') || parsed.hostname.startsWith('172.31.') || parsed.hostname.endsWith('.local') ) { return url } console.error('❌ Only local URLs are allowed for security') process.exit(1) } catch { console.error('❌ Invalid URL format') process.exit(1) } } constructor(config: Partial<ChatApiConfig> = {}) { this.config = { apiUrl: this.validateUrl(config.apiUrl) || 'http://localhost:3000/api/chat', message: config.message || DEFAULT_MESSAGE, chatId: config.chatId || this.generateId(), modelType: config.modelType || 'speed', searchMode: config.searchMode ?? 'adaptive', trigger: config.trigger || 'submit-message', messageId: config.messageId } } private generateId(): string { return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` } private createUserMessage(text: string, messageId?: string): UIMessage { // Limit message length for safety const sanitizedText = text.slice(0, 10000) return { id: messageId || this.generateId(), role: 'user', content: sanitizedText, parts: [ { type: 'text', text: sanitizedText } ], createdAt: new Date() } } private loadCookiesFromEnv(): string | undefined { if (process.env.MORPHIC_COOKIES) { console.log('🍪 Using cookies from MORPHIC_COOKIES environment variable') return process.env.MORPHIC_COOKIES } return undefined } async sendMessage(message?: string): Promise<void> { // Load cookies from env const cookies = this.loadCookiesFromEnv() const payload: ChatPayload = { chatId: this.config.chatId!, trigger: this.config.trigger || 'submit-message' } // Add message for submit trigger or messageId for regenerate if (this.config.trigger === 'regenerate-message') { if (!this.config.messageId) { console.error('❌ Message ID is required for regeneration') process.exit(1) } payload.messageId = this.config.messageId // Only include message if we're editing a user message if (message && message !== DEFAULT_MESSAGE) { // Use the same messageId for the edited message const userMessage = this.createUserMessage( message, this.config.messageId ) payload.message = userMessage } } else { const userMessage = this.createUserMessage(message || this.config.message) payload.message = userMessage // Add isNewChat flag - always true for CLI since we generate new chatId each time payload.isNewChat = true } console.log('🚀 Sending request to:', this.config.apiUrl) console.log('📦 Payload:', JSON.stringify(payload, null, 2)) console.log('🤖 Model Type:', this.config.modelType) console.log('🔍 Search Mode:', this.config.searchMode) console.log('💬 Chat ID:', this.config.chatId) console.log('\n---\n') // Build cookie string let cookieString = '' if (cookies) { // If cookies from env exist, append our settings to them cookieString = cookies if (!cookieString.includes('modelType=')) { cookieString += `; modelType=${this.config.modelType}` } if (!cookieString.includes('searchMode=')) { const searchModeValue = this.config.searchMode === false ? 'disabled' : this.config.searchMode cookieString += `; searchMode=${searchModeValue}` } } else { // If no cookies from env, just use our settings const searchModeValue = this.config.searchMode === false ? 'disabled' : this.config.searchMode cookieString = [ `modelType=${this.config.modelType}`, `searchMode=${searchModeValue}` ].join('; ') } const headers = { 'Content-Type': 'application/json', Cookie: cookieString } // Only show headers in debug mode, without sensitive data if (process.env.DEBUG) { console.log('🔐 Request headers:') console.log(` Content-Type: ${headers['Content-Type']}`) console.log(` Cookie: [REDACTED]`) } try { const response = await fetch(this.config.apiUrl, { method: 'POST', headers, body: JSON.stringify(payload) }) if (!response.ok) { const errorText = await response.text() throw new Error( `HTTP ${response.status}: ${response.statusText}\n${errorText}` ) } if (!response.body) { throw new Error('No response body') } console.log('📡 Response received, starting stream...\n') // Convert Web Streams API to Node.js stream // Type assertion needed due to Node.js/Web Streams API type mismatch // response.body is guaranteed to be a ReadableStream at this point const webStream = response.body as unknown as NodeReadableStream<any> const nodeReadable = Readable.fromWeb(webStream) // Create parser stream // Process SSE stream const textDecoder = new TextDecoder() let buffer = '' let hasReceivedData = false for await (const chunk of nodeReadable) { buffer += textDecoder.decode(chunk, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (line.trim() === '') continue // Debug: log raw lines if (process.env.DEBUG) { console.log(`🔍 Raw line: ${line}`) } if (line.startsWith('data: ')) { hasReceivedData = true const data = line.slice(6) if (data === '[DONE]') { console.log('\n\n✅ Stream finished') continue } try { const parsed = JSON.parse(data) if (parsed.type === 'text') { process.stdout.write(parsed.text || '') } else if (parsed.type === 'text-delta') { process.stdout.write(parsed.delta || '') } else if (parsed.type === 'reasoning') { console.log(`\n🤔 Reasoning: ${parsed.text}`) } else if (parsed.type?.startsWith('tool-')) { console.log(`\n🔧 Tool: ${parsed.type}`) console.log(` State: ${parsed.state}`) if (parsed.input) { console.log(` Input: ${JSON.stringify(parsed.input)}`) } if (parsed.output) { console.log(` Output: ${JSON.stringify(parsed.output)}`) } } else if (parsed.type === 'finish') { console.log('\n\n✅ Stream completed') } else if (parsed.type === 'error') { console.error( `\n❌ Error: ${parsed.error || parsed.errorText || JSON.stringify(parsed)}` ) } else if (process.env.DEBUG) { // Only show raw event types in debug mode console.log(`\n📦 Event type: ${parsed.type || 'unknown'}`) } } catch (error) { if (process.env.DEBUG) { console.log(`📄 Parse error for data`) } } } else if (line.startsWith('event: ')) { const eventName = line.slice(7) console.log(`📨 Event: ${eventName}`) } } } if (!hasReceivedData) { console.log('\n⚠️ No data received from stream') } console.log('\n\n✨ Request completed successfully') } catch (error) { console.error( '\n❌ Error:', error instanceof Error ? error.message : error ) process.exit(1) } } } // Parse command line arguments function parseArgs(): Partial<ChatApiConfig> { const args = process.argv.slice(2) const config: Partial<ChatApiConfig> = {} for (let i = 0; i < args.length; i++) { switch (args[i]) { case '-m': case '--message': config.message = args[++i] break case '-u': case '--url': config.apiUrl = args[++i] break case '-c': case '--chat-id': config.chatId = args[++i] break case '-s': case '--search': config.searchMode = 'adaptive' break case '--no-search': config.searchMode = false break case '--search-mode': const searchMode = args[++i] if (['quick', 'adaptive'].includes(searchMode)) { config.searchMode = searchMode as 'quick' | 'adaptive' } else { console.error('❌ Invalid search mode. Use: quick or adaptive') process.exit(1) } break case '--model-type': const modelType = args[++i] if (['speed', 'quality'].includes(modelType)) { config.modelType = modelType as 'speed' | 'quality' } else { console.error('❌ Invalid model type. Use: speed or quality') process.exit(1) } break case '-t': case '--trigger': const trigger = args[++i] if (trigger === 'regenerate' || trigger === 'regenerate-message') { config.trigger = 'regenerate-message' } else { config.trigger = 'submit-message' } break case '--message-id': config.messageId = args[++i] break case '-h': case '--help': console.log(` Chat API Test Script Usage: bun scripts/chat-cli.ts [options] Options: -m, --message <text> Message to send (default: "Hello, how are you?") -u, --url <url> API URL (default: http://localhost:3000/api/chat) -c, --chat-id <id> Chat ID (default: auto-generated) -s, --search Enable search mode with adaptive strategy (default) --no-search Disable search mode --search-mode <type> Search strategy: quick or adaptive --model-type <type> Model type: speed (default) or quality -t, --trigger <type> Trigger type: submit (default) or regenerate --message-id <id> Message ID (required for regenerate) -h, --help Show this help message Examples: # Simple test bun scripts/chat-cli.ts -m "What is the weather like?" # Without search mode bun scripts/chat-cli.ts -m "Tell me a joke" --no-search # With quality model type bun scripts/chat-cli.ts -m "Tell me a joke" --model-type quality # Continue existing chat bun scripts/chat-cli.ts -c "chat_123" -m "Tell me more" # Regenerate assistant message bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123" # Edit user message and regenerate bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123" -m "New message" Note: Without authentication, you may get "User not authenticated" errors. Authentication: 1. Add to .env.local: MORPHIC_COOKIES="your-cookie-string" 2. The script will automatically load cookies when it runs To get cookies: 1. Open DevTools > Network tab 2. Make any request in Morphic 3. Click on the request > Headers > Request Headers > Cookie 4. Copy the entire Cookie value 5. Add to .env.local as MORPHIC_COOKIES="copied-value" `) process.exit(0) } } return config } // Main execution async function main() { const config = parseArgs() const tester = new ChatApiTester(config) await tester.sendMessage(config.message) } // Run the script main().catch(error => { console.error('Fatal error:', error) process.exit(1) }) ================================================ FILE: scripts/test-cache-performance.ts ================================================ #!/usr/bin/env bun import { config } from 'dotenv' config({ path: '.env.local' }) const API_URL = process.env.API_URL || 'http://localhost:3001/api/chat' const COOKIES = process.env.MORPHIC_COOKIES async function measureRequest( chatId: string, message: string, trigger: string = 'submit-user-message', messageId?: string ): Promise<number> { const startTime = performance.now() const payload: any = { chatId, trigger } if (trigger === 'submit-user-message') { payload.message = { id: `msg_${Date.now()}_${Math.random().toString(36).substring(7)}`, role: 'user', content: message, parts: [{ type: 'text', text: message }], createdAt: new Date().toISOString() } } else if (trigger === 'regenerate-assistant-message' && messageId) { payload.messageId = messageId } const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: COOKIES || '' }, body: JSON.stringify(payload) }) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } // Consume the stream const reader = response.body?.getReader() if (reader) { while (true) { const { done } = await reader.read() if (done) break } } const endTime = performance.now() return endTime - startTime } async function runPerformanceTests() { console.log('🚀 Cache Performance Test Suite') console.log('================================\n') const results = { firstRequest: 0, cachedRequest: 0, regeneration: 0, consecutiveRequests: [] as number[] } // Test 1: First request (cold cache) console.log('Test 1: First request (cold cache)') const chatId1 = `chat_perf_${Date.now()}_1` results.firstRequest = await measureRequest( chatId1, 'What is the capital of France?' ) console.log(`✓ Time: ${results.firstRequest.toFixed(2)}ms\n`) // Test 2: Subsequent request (warm cache) console.log('Test 2: Subsequent request (warm cache)') results.cachedRequest = await measureRequest(chatId1, 'What about Germany?') console.log(`✓ Time: ${results.cachedRequest.toFixed(2)}ms\n`) // Test 3: Regeneration (should bypass cache) console.log('Test 3: Regeneration (bypass cache)') const messageId = `msg_${Date.now()}_test` results.regeneration = await measureRequest( chatId1, '', 'regenerate-assistant-message', messageId ) console.log(`✓ Time: ${results.regeneration.toFixed(2)}ms\n`) // Test 4: Multiple consecutive requests console.log('Test 4: Multiple consecutive requests') const chatId2 = `chat_perf_${Date.now()}_2` for (let i = 1; i <= 5; i++) { const time = await measureRequest(chatId2, `Message ${i}`) results.consecutiveRequests.push(time) console.log(` Request ${i}: ${time.toFixed(2)}ms`) } // Summary console.log('\n================================') console.log('📊 Performance Summary') console.log('================================') console.log(`First Request: ${results.firstRequest.toFixed(2)}ms`) console.log(`Cached Request: ${results.cachedRequest.toFixed(2)}ms`) console.log(`Regeneration: ${results.regeneration.toFixed(2)}ms`) const avgConsecutive = results.consecutiveRequests.reduce((a, b) => a + b, 0) / results.consecutiveRequests.length console.log(`Avg Consecutive: ${avgConsecutive.toFixed(2)}ms`) // Cache effectiveness const cacheImprovement = ((results.firstRequest - results.cachedRequest) / results.firstRequest) * 100 console.log(`\nCache Improvement: ${cacheImprovement.toFixed(1)}%`) // Check if cache is working effectively if (results.cachedRequest < results.firstRequest) { console.log('✅ Cache is working effectively!') } else { console.log('⚠️ Cache may not be working as expected') } } // Run tests runPerformanceTests().catch(console.error) ================================================ FILE: searxng-limiter.toml ================================================ #https://docs.searxng.org/admin/searx.limiter.html ================================================ FILE: searxng-settings.yml ================================================ use_default_settings: true server: # Is overwritten by ${SEARXNG_PORT} and ${SEARXNG_BIND_ADDRESS} port: 8888 bind_address: '0.0.0.0' # public URL of the instance, to ensure correct inbound links. Is overwritten # by ${SEARXNG_URL}. base_url: false # "http://example.com/location" # rate limit the number of request on the instance, block some bots. # Is overwritten by ${SEARXNG_LIMITER} limiter: false # enable features designed only for public instances. # Is overwritten by ${SEARXNG_PUBLIC_INSTANCE} public_instance: false # If your instance owns a /etc/searxng/settings.yml file, then set the following # values there. secret_key: 'ursecretkey' # Is overwritten by ${SEARXNG_SECRET} # Proxy image results through SearXNG. Is overwritten by ${SEARXNG_IMAGE_PROXY} image_proxy: false # 1.0 and 1.1 are supported http_protocol_version: '1.0' # POST queries are more secure as they don't show up in history but may cause # problems when using Firefox containers method: 'POST' default_http_headers: X-Content-Type-Options: nosniff X-Download-Options: noopen X-Robots-Tag: noindex, nofollow Referrer-Policy: no-referrer search: formats: - json ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"], "zod/v4": ["./node_modules/zod"] }, "target": "ES2017" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": ["node_modules"] } ================================================ FILE: vitest.config.mts ================================================ import react from '@vitejs/plugin-react' import path from 'path' import { defineConfig } from 'vitest/config' // Provide dummy env vars at configuration time to avoid import errors during bundling process.env.DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/testdb' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './'), 'zod/v4': 'zod' } }, test: { globals: true, environment: 'jsdom', setupFiles: './vitest.setup.ts' } }) ================================================ FILE: vitest.setup.ts ================================================ import { vi } from 'vitest' import '@testing-library/jest-dom' // Provide dummy values for environment variables required during tests process.env.DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/testdb' // Mock Next.js functions vi.mock('next/cache', () => ({ revalidateTag: vi.fn(), revalidatePath: vi.fn(), unstable_cache: vi.fn(fn => fn) }))