Showing preview only (1,086K chars total). Download the full file or copy to clipboard to get everything.
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] <title>'
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.
[](https://deepwiki.com/miurla/morphic) [](https://github.com/miurla/morphic/stargazers) [](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
[](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"
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
SYMBOL INDEX (611 symbols across 200 files)
FILE: app/api/advanced-search/route.ts
constant SEARXNG_MAX_RESULTS (line 22) | const SEARXNG_MAX_RESULTS = Math.max(
constant CACHE_TTL (line 27) | const CACHE_TTL = 3600 // Cache time-to-live in seconds (1 hour)
constant CACHE_EXPIRATION_CHECK_INTERVAL (line 28) | const CACHE_EXPIRATION_CHECK_INTERVAL = 3600000 // 1 hour in milliseconds
function initializeRedisClient (line 33) | async function initializeRedisClient() {
function getCachedResults (line 67) | async function getCachedResults(
function setCachedResults (line 95) | async function setCachedResults(
function cleanupExpiredCache (line 116) | async function cleanupExpiredCache() {
function POST (line 137) | async function POST(request: Request) {
function advancedSearchXNGSearch (line 183) | async function advancedSearchXNGSearch(
function crawlPage (line 313) | async function crawlPage(
function highlightQueryTerms (line 409) | function highlightQueryTerms(content: string, query: string): string {
function calculateRelevanceScore (line 434) | function calculateRelevanceScore(result: SearXNGResult, query: string): ...
function extractPublicationDate (line 503) | function extractPublicationDate(document: Document): Date | null {
function fetchJsonWithRetry (line 539) | async function fetchJsonWithRetry(url: string, retries: number): Promise...
function fetchJson (line 550) | function fetchJson(url: string): Promise<any> {
function fetchHtmlWithTimeout (line 586) | async function fetchHtmlWithTimeout(
function fetchHtml (line 602) | function fetchHtml(url: string): Promise<string> {
function timeout (line 638) | function timeout(ms: number, message: string): Promise<never> {
function isQualityContent (line 646) | function isQualityContent(text: string): boolean {
FILE: app/api/chat/route.ts
function POST (line 19) | async function POST(req: Request) {
FILE: app/api/chats/route.ts
type ChatPageResponse (line 6) | interface ChatPageResponse {
function GET (line 11) | async function GET(request: NextRequest) {
FILE: app/api/feedback/route.ts
function POST (line 7) | async function POST(req: Request) {
FILE: app/api/upload/route.ts
constant MAX_FILE_SIZE (line 12) | const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
constant ALLOWED_TYPES (line 13) | const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']
function POST (line 15) | async function POST(req: NextRequest) {
function sanitizeFilename (line 60) | function sanitizeFilename(filename: string) {
function uploadFileToR2 (line 64) | async function uploadFileToR2(file: File, userId: string, chatId: string) {
FILE: app/auth/confirm/route.ts
function GET (line 8) | async function GET(request: NextRequest) {
FILE: app/auth/error/page.tsx
function Page (line 3) | async function Page({
FILE: app/auth/forgot-password/page.tsx
function Page (line 3) | function Page() {
FILE: app/auth/login/page.tsx
function Page (line 3) | function Page() {
FILE: app/auth/oauth/route.ts
function GET (line 6) | async function GET(request: Request) {
FILE: app/auth/sign-up-success/page.tsx
function Page (line 9) | function Page() {
FILE: app/auth/sign-up/page.tsx
function Page (line 3) | function Page() {
FILE: app/auth/update-password/page.tsx
function Page (line 3) | function Page() {
FILE: app/layout.tsx
function RootLayout (line 52) | async function RootLayout({
FILE: app/page.tsx
function Page (line 5) | async function Page() {
FILE: app/search/[id]/page.tsx
function generateMetadata (line 12) | async function generateMetadata(props: {
function SearchPage (line 29) | async function SearchPage(props: {
FILE: app/search/loading.tsx
function Loading (line 5) | function Loading() {
FILE: app/search/page.tsx
function SearchPage (line 10) | async function SearchPage(props: {
FILE: components/action-buttons.tsx
constant FOCUS_OUT_DELAY_MS (line 19) | const FOCUS_OUT_DELAY_MS = 100 // Delay to ensure focus has actually moved
type ActionCategory (line 21) | interface ActionCategory {
type ActionButtonsProps (line 88) | interface ActionButtonsProps {
function ActionButtons (line 95) | function ActionButtons({
FILE: components/answer-section.tsx
type AnswerSectionProps (line 18) | type AnswerSectionProps = {
function AnswerSection (line 35) | function AnswerSection({
FILE: components/app-sidebar.tsx
function AppSidebar (line 23) | function AppSidebar() {
FILE: components/artifact/artifact-content.tsx
function isTodoToolPart (line 10) | function isTodoToolPart(part: Part): part is ToolPart<'todoWrite'> {
function ArtifactContent (line 14) | function ArtifactContent({ part }: { part: Part | null }) {
FILE: components/artifact/artifact-context.tsx
constant ANIMATION_DURATION (line 17) | const ANIMATION_DURATION = 300
type ArtifactState (line 19) | interface ArtifactState {
type ArtifactAction (line 24) | type ArtifactAction =
function artifactReducer (line 34) | function artifactReducer(
type ArtifactContextValue (line 50) | interface ArtifactContextValue {
function ArtifactProvider (line 60) | function ArtifactProvider({ children }: { children: ReactNode }) {
function useArtifact (line 91) | function useArtifact() {
FILE: components/artifact/artifact-root.tsx
function ArtifactRoot (line 8) | function ArtifactRoot({ children }: { children: ReactNode }) {
FILE: components/artifact/chat-artifact-container.tsx
constant DEFAULT_WIDTH (line 15) | const DEFAULT_WIDTH = 500
constant MIN_WIDTH (line 16) | const MIN_WIDTH = 320
constant MAX_WIDTH (line 17) | const MAX_WIDTH = 800
constant CHAT_MIN_WIDTH (line 18) | const CHAT_MIN_WIDTH = 360
constant RESIZE_OVERLAY_Z_INDEX (line 19) | const RESIZE_OVERLAY_Z_INDEX = 9999
function getAllowedWidthBounds (line 22) | function getAllowedWidthBounds(containerWidth: number): {
function ChatArtifactContainer (line 39) | function ChatArtifactContainer({
FILE: components/artifact/reasoning-content.tsx
function ReasoningContent (line 8) | function ReasoningContent({ reasoning }: { reasoning: string }) {
FILE: components/artifact/search-artifact-content.tsx
function SearchArtifactContent (line 14) | function SearchArtifactContent({ tool }: { tool: ToolPart<'search'> }) {
FILE: components/artifact/todo-invocation-content.tsx
type TodoInvocationContentProps (line 7) | interface TodoInvocationContentProps {
function TodoInvocationContent (line 11) | function TodoInvocationContent({ part }: TodoInvocationContentProps) {
FILE: components/artifact/tool-invocation-content.tsx
function ToolInvocationContent (line 7) | function ToolInvocationContent({ part }: { part: ToolPart }) {
FILE: components/attachment-preview.tsx
type Attachment (line 5) | interface Attachment {
type AttachmentPreviewProps (line 11) | interface AttachmentPreviewProps {
FILE: components/auth-modal.tsx
type AuthModalProps (line 15) | interface AuthModalProps {
function AuthModal (line 20) | function AuthModal({ open, onOpenChange }: AuthModalProps) {
FILE: components/chat-error.tsx
type ChatErrorProps (line 5) | interface ChatErrorProps {
function ChatError (line 9) | function ChatError({ error }: ChatErrorProps) {
FILE: components/chat-messages.tsx
type ChatSection (line 17) | interface ChatSection {
type ChatMessagesProps (line 23) | interface ChatMessagesProps {
function ChatMessages (line 37) | function ChatMessages({
FILE: components/chat-panel.tsx
constant INPUT_UPDATE_DELAY_MS (line 25) | const INPUT_UPDATE_DELAY_MS = 10 // Delay to ensure input value is updat...
type ChatPanelProps (line 27) | interface ChatPanelProps {
function ChatPanel (line 50) | function ChatPanel({
FILE: components/chat-share.tsx
type ChatShareProps (line 24) | interface ChatShareProps {
function ChatShare (line 29) | function ChatShare({ chatId, className }: ChatShareProps) {
FILE: components/chat.tsx
type ChatSection (line 28) | interface ChatSection {
function Chat (line 34) | function Chat({
FILE: components/citation-context.tsx
type CitationContextValue (line 7) | interface CitationContextValue {
function CitationProvider (line 15) | function CitationProvider({
function useCitation (line 29) | function useCitation() {
FILE: components/citation-link.tsx
type CitationLinkProps (line 16) | interface CitationLinkProps {
FILE: components/collapsible-message.tsx
type CollapsibleMessageProps (line 14) | interface CollapsibleMessageProps {
function CollapsibleMessage (line 30) | function CollapsibleMessage({
FILE: components/custom-link.tsx
type CustomLinkProps (line 9) | type CustomLinkProps = Omit<
function Citing (line 14) | function Citing({
FILE: components/data-section.tsx
type DataSectionProps (line 9) | interface DataSectionProps {
function DataSection (line 14) | function DataSection({ part, onQuerySelect }: DataSectionProps) {
FILE: components/default-skeleton.tsx
function SearchSkeleton (line 15) | function SearchSkeleton() {
FILE: components/drag-overlay.tsx
function DragOverlay (line 6) | function DragOverlay({ visible }: { visible: boolean }) {
FILE: components/dynamic-tool-display.tsx
type DynamicToolPart (line 6) | type DynamicToolPart =
type DynamicToolDisplayProps (line 38) | interface DynamicToolDisplayProps {
function DynamicToolDisplay (line 42) | function DynamicToolDisplay({ part }: DynamicToolDisplayProps) {
FILE: components/error-modal.tsx
type ErrorModalProps (line 17) | interface ErrorModalProps {
function ErrorModal (line 29) | function ErrorModal({
FILE: components/external-link-items.tsx
function ExternalLinkItems (line 26) | function ExternalLinkItems() {
FILE: components/feedback-modal.tsx
type Sentiment (line 21) | type Sentiment = 'positive' | 'neutral' | 'negative'
type FeedbackModalProps (line 23) | interface FeedbackModalProps {
function FeedbackModal (line 28) | function FeedbackModal({ open, onOpenChange }: FeedbackModalProps) {
FILE: components/fetch-section.tsx
type FetchSectionProps (line 12) | interface FetchSectionProps {
function FetchSection (line 22) | function FetchSection({
FILE: components/file-upload-button.tsx
function FileUploadButton (line 22) | function FileUploadButton({
FILE: components/forgot-password-form.tsx
function ForgotPasswordForm (line 20) | function ForgotPasswordForm({
FILE: components/guest-menu.tsx
function GuestMenu (line 27) | function GuestMenu() {
FILE: components/header.tsx
type HeaderProps (line 19) | interface HeaderProps {
FILE: components/inspector/inspector-drawer.tsx
function InspectorDrawer (line 13) | function InspectorDrawer() {
FILE: components/inspector/inspector-panel.tsx
function InspectorPanel (line 18) | function InspectorPanel() {
FILE: components/login-form.tsx
function LoginForm (line 24) | function LoginForm({
FILE: components/message-actions.tsx
type MessageActionsProps (line 18) | interface MessageActionsProps {
function MessageActions (line 32) | function MessageActions({
FILE: components/message.tsx
function MarkdownMessage (line 18) | function MarkdownMessage({
FILE: components/model-type-selector.tsx
constant MODEL_TYPE_OPTIONS (line 18) | const MODEL_TYPE_OPTIONS: { value: ModelType; label: string }[] = [
function ModelTypeSelector (line 23) | function ModelTypeSelector({
FILE: components/process-header.tsx
type ProcessHeaderProps (line 7) | type ProcessHeaderProps = {
function ProcessHeader (line 16) | function ProcessHeader({
FILE: components/process-rail.tsx
type ProcessRailProps (line 3) | interface ProcessRailProps {
function ProcessRail (line 9) | function ProcessRail({
FILE: components/question-confirmation.tsx
type QuestionConfirmationProps (line 14) | interface QuestionConfirmationProps {
type QuestionOption (line 20) | interface QuestionOption {
type QuestionInput (line 25) | interface QuestionInput {
type QuestionOutput (line 33) | interface QuestionOutput {
function QuestionConfirmation (line 39) | function QuestionConfirmation({
FILE: components/reasoning-section.tsx
type ReasoningContent (line 16) | interface ReasoningContent {
type ReasoningSectionProps (line 21) | interface ReasoningSectionProps {
function ReasoningSection (line 32) | function ReasoningSection({
FILE: components/related-questions.tsx
type RelatedQuestionsProps (line 14) | interface RelatedQuestionsProps {
FILE: components/render-message.tsx
type RenderMessageProps (line 18) | interface RenderMessageProps {
function RenderMessage (line 34) | function RenderMessage({
FILE: components/research-process-section.tsx
type TextPart (line 29) | type TextPart = {
type DataPart (line 34) | type DataPart = UIDataPart
type MessagePart (line 36) | type MessagePart =
function isReasoningPart (line 44) | function isReasoningPart(part: MessagePart): part is ReasoningPart {
function isToolPart (line 48) | function isToolPart(part: MessagePart): part is ToolPart {
function isTextPart (line 54) | function isTextPart(part: MessagePart): part is TextPart {
function isDataPart (line 58) | function isDataPart(part: MessagePart): part is DataPart {
type Props (line 62) | type Props = {
function splitByText (line 80) | function splitByText(parts: MessagePart[]): MessagePart[][] {
function groupConsecutiveParts (line 110) | function groupConsecutiveParts(segment: MessagePart[]): MessagePart[][] {
function useAccordionState (line 148) | function useAccordionState(onOpenChange: (id: string, open: boolean) => ...
function RenderPart (line 176) | function RenderPart({
function useHasSubsequentContent (line 264) | function useHasSubsequentContent(
function ResearchProcessSection (line 293) | function ResearchProcessSection({
FILE: components/retry-button.tsx
type RetryButtonProps (line 7) | interface RetryButtonProps {
FILE: components/search-mode-selector.tsx
function SearchModeSelector (line 21) | function SearchModeSelector() {
FILE: components/search-results-image.tsx
type SearchResultsImageSectionProps (line 33) | interface SearchResultsImageSectionProps {
type NormalizedImage (line 39) | type NormalizedImage = { id: string; url: string; description: string }
type FilterStatus (line 41) | type FilterStatus = 'loading' | 'ready' | 'empty'
type FilteredImagesState (line 43) | interface FilteredImagesState {
FILE: components/search-results.tsx
type SearchResultsProps (line 13) | interface SearchResultsProps {
function SearchResults (line 18) | function SearchResults({
FILE: components/search-section.tsx
type SearchSectionProps (line 25) | interface SearchSectionProps {
function SearchSection (line 35) | function SearchSection({
FILE: components/section.tsx
type SectionProps (line 24) | type SectionProps = {
function ToolArgsSection (line 100) | function ToolArgsSection({
FILE: components/sidebar/chat-history-client.tsx
type ChatPageResponse (line 19) | interface ChatPageResponse {
function ChatHistoryClient (line 24) | function ChatHistoryClient() {
FILE: components/sidebar/chat-history-section.tsx
function ChatHistorySection (line 3) | async function ChatHistorySection() {
FILE: components/sidebar/chat-history-skeleton.tsx
function ChatHistorySkeleton (line 7) | function ChatHistorySkeleton() {
FILE: components/sidebar/chat-menu-item.tsx
type ChatMenuItemProps (line 38) | interface ChatMenuItemProps {
function ChatMenuItem (line 80) | function ChatMenuItem({ chat }: ChatMenuItemProps) {
FILE: components/sidebar/clear-history-action.tsx
type ClearHistoryActionProps (line 31) | interface ClearHistoryActionProps {
function ClearHistoryAction (line 35) | function ClearHistoryAction({ empty }: ClearHistoryActionProps) {
FILE: components/sign-up-form.tsx
function SignUpForm (line 22) | function SignUpForm({
FILE: components/source-favicons.tsx
type SourceFaviconsProps (line 6) | interface SourceFaviconsProps {
function SourceFavicons (line 15) | function SourceFavicons({
FILE: components/theme-menu-items.tsx
function ThemeMenuItems (line 9) | function ThemeMenuItems() {
FILE: components/theme-provider.tsx
function ThemeProvider (line 7) | function ThemeProvider({ children, ...props }: ThemeProviderProps) {
FILE: components/todo-list-content.tsx
type TodoListContentProps (line 8) | type TodoListContentProps = {
function TodoListContent (line 20) | function TodoListContent({
FILE: components/tool-badge.tsx
type ToolBadgeProps (line 9) | type ToolBadgeProps = {
FILE: components/tool-section.tsx
type ToolSectionProps (line 12) | interface ToolSectionProps {
function ToolSection (line 24) | function ToolSection({
FILE: components/tool-todo-display.tsx
type ToolTodoDisplayProps (line 11) | interface ToolTodoDisplayProps {
function ToolTodoDisplay (line 36) | function ToolTodoDisplay({
FILE: components/ui/animated-logo.tsx
function AnimatedLogo (line 5) | function AnimatedLogo({
FILE: components/ui/badge.tsx
type BadgeProps (line 27) | interface BadgeProps
function Badge (line 31) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: components/ui/button.tsx
type ButtonProps (line 37) | interface ButtonProps
FILE: components/ui/carousel.tsx
type CarouselApi (line 14) | type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters (line 15) | type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions (line 16) | type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin (line 17) | type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps (line 19) | type CarouselProps = {
type CarouselContextProps (line 26) | type CarouselContextProps = {
function useCarousel (line 37) | function useCarousel() {
FILE: components/ui/dropdown-menu.tsx
function DropdownMenu (line 10) | function DropdownMenu({
function DropdownMenuPortal (line 16) | function DropdownMenuPortal({
function DropdownMenuTrigger (line 24) | function DropdownMenuTrigger({
function DropdownMenuContent (line 35) | function DropdownMenuContent({
function DropdownMenuGroup (line 55) | function DropdownMenuGroup({
function DropdownMenuItem (line 63) | function DropdownMenuItem({
function DropdownMenuCheckboxItem (line 86) | function DropdownMenuCheckboxItem({
function DropdownMenuRadioGroup (line 112) | function DropdownMenuRadioGroup({
function DropdownMenuRadioItem (line 123) | function DropdownMenuRadioItem({
function DropdownMenuLabel (line 147) | function DropdownMenuLabel({
function DropdownMenuSeparator (line 167) | function DropdownMenuSeparator({
function DropdownMenuShortcut (line 180) | function DropdownMenuShortcut({
function DropdownMenuSub (line 196) | function DropdownMenuSub({
function DropdownMenuSubTrigger (line 202) | function DropdownMenuSubTrigger({
function DropdownMenuSubContent (line 226) | function DropdownMenuSubContent({
FILE: components/ui/icons.tsx
function IconLogo (line 7) | function IconLogo({ className, ...props }: React.ComponentProps<'svg'>) {
function IconLogoOutline (line 24) | function IconLogoOutline({ className, ...props }: React.ComponentProps<'...
function IconBlinkingLogo (line 48) | function IconBlinkingLogo({
FILE: components/ui/input.tsx
type InputProps (line 5) | interface InputProps
FILE: components/ui/sheet.tsx
type SheetContentProps (line 53) | interface SheetContentProps
FILE: components/ui/sidebar.tsx
constant SIDEBAR_COOKIE_NAME (line 31) | const SIDEBAR_COOKIE_NAME = 'sidebar_state'
constant SIDEBAR_COOKIE_MAX_AGE (line 32) | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
constant SIDEBAR_WIDTH (line 33) | const SIDEBAR_WIDTH = '16rem'
constant SIDEBAR_WIDTH_MOBILE (line 34) | const SIDEBAR_WIDTH_MOBILE = '18rem'
constant SIDEBAR_WIDTH_ICON (line 35) | const SIDEBAR_WIDTH_ICON = '3rem'
constant SIDEBAR_KEYBOARD_SHORTCUT (line 36) | const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps (line 38) | type SidebarContextProps = {
function useSidebar (line 50) | function useSidebar() {
function getCookie (line 60) | function getCookie(name: string): string | null {
FILE: components/ui/skeleton.tsx
function Skeleton (line 3) | function Skeleton({
FILE: components/ui/sonner.tsx
type ToasterProps (line 7) | type ToasterProps = React.ComponentProps<typeof Sonner>
FILE: components/ui/spinner.tsx
type SpinnerProps (line 7) | interface SpinnerProps extends React.SVGProps<SVGSVGElement> {}
FILE: components/ui/status-indicator.tsx
type StatusIndicatorProps (line 5) | interface StatusIndicatorProps {
function StatusIndicator (line 11) | function StatusIndicator({
FILE: components/ui/textarea.tsx
type TextareaProps (line 5) | interface TextareaProps
FILE: components/ui/tooltip-button.tsx
type TooltipButtonProps (line 12) | interface TooltipButtonProps extends ButtonProps {
FILE: components/update-password-form.tsx
function UpdatePasswordForm (line 20) | function UpdatePasswordForm({
FILE: components/uploaded-file-list.tsx
type UploadedFileListProps (line 10) | interface UploadedFileListProps {
FILE: components/user-file-section.tsx
type UserFileSectionProps (line 5) | interface UserFileSectionProps {
FILE: components/user-menu.tsx
type UserMenuProps (line 27) | interface UserMenuProps {
function UserMenu (line 31) | function UserMenu({ user }: UserMenuProps) {
FILE: components/user-text-section.tsx
type UserTextSectionProps (line 13) | interface UserTextSectionProps {
FILE: components/video-carousel-dialog.tsx
type VideoCarouselDialogProps (line 24) | interface VideoCarouselDialogProps {
function VideoCarouselDialog (line 31) | function VideoCarouselDialog({
FILE: components/video-result-grid.tsx
type VideoResultGridProps (line 14) | interface VideoResultGridProps {
function VideoResultGrid (line 20) | function VideoResultGrid({
FILE: components/video-search-results.tsx
type VideoSearchResultsProps (line 8) | interface VideoSearchResultsProps {
function createVideoSearchResults (line 14) | function createVideoSearchResults(
function VideoSearchResults (line 29) | function VideoSearchResults({
FILE: drizzle/0000_black_lifeguard.sql
type "chats" (line 1) | CREATE TABLE "chats" (
type "messages" (line 9) | CREATE TABLE "messages" (
FILE: drizzle/0003_heavy_whirlwind.sql
type "feedback" (line 1) | CREATE TABLE "feedback" (
type "feedback" (line 11) | CREATE INDEX "feedback_user_id_idx" ON "feedback" USING btree ("user_id")
type "feedback" (line 12) | CREATE INDEX "feedback_created_at_idx" ON "feedback" USING btree ("creat...
FILE: drizzle/0004_natural_wallow.sql
type "chats" (line 1) | CREATE INDEX "chats_user_id_idx" ON "chats" USING btree ("user_id")
type "chats" (line 2) | CREATE INDEX "chats_user_id_created_at_idx" ON "chats" USING btree ("use...
type "chats" (line 3) | CREATE INDEX "chats_created_at_idx" ON "chats" USING btree ("created_at"...
FILE: drizzle/0009_thankful_may_parker.sql
type "chats" (line 1) | CREATE INDEX "chats_id_user_id_idx" ON "chats" USING btree ("id","user_id")
FILE: hooks/use-auth-check.tsx
function useAuthCheck (line 9) | function useAuthCheck() {
FILE: hooks/use-file-dropzone.ts
type UseFileDropzoneProps (line 7) | type UseFileDropzoneProps = {
function useFileDropzone (line 15) | function useFileDropzone({
FILE: hooks/use-mobile.tsx
constant MOBILE_BREAKPOINT (line 3) | const MOBILE_BREAKPOINT = 768
function useIsMobile (line 5) | function useIsMobile() {
FILE: instrumentation.ts
function register (line 4) | async function register() {
FILE: lib/actions/chat.ts
constant DEFAULT_CHAT_TITLE (line 14) | const DEFAULT_CHAT_TITLE = 'Untitled'
function getChats (line 39) | async function getChats() {
function getChatsPage (line 50) | async function getChatsPage(limit = 20, offset = 0) {
function loadChat (line 63) | async function loadChat(
function createChat (line 75) | async function createChat(
function createChatAndSaveMessage (line 100) | async function createChatAndSaveMessage(
function createChatWithFirstMessage (line 141) | async function createChatWithFirstMessage(
function upsertMessage (line 179) | async function upsertMessage(
function deleteChat (line 205) | async function deleteChat(chatId: string) {
function clearChats (line 223) | async function clearChats() {
function deleteMessagesAfter (line 243) | async function deleteMessagesAfter(chatId: string, messageId: string) {
function shareChat (line 265) | async function shareChat(chatId: string) {
function deleteMessagesFromIndex (line 287) | async function deleteMessagesFromIndex(
function saveChatTitle (line 321) | async function saveChatTitle(
FILE: lib/actions/feedback.ts
function updateMessageFeedback (line 12) | async function updateMessageFeedback(
function getMessageFeedback (line 77) | async function getMessageFeedback(
FILE: lib/actions/site-feedback.ts
function submitFeedback (line 8) | async function submitFeedback(data: {
FILE: lib/agents/generate-related-questions.ts
function createRelatedQuestionsStream (line 10) | function createRelatedQuestionsStream(
FILE: lib/agents/prompts/related-questions-prompt.ts
constant RELATED_QUESTIONS_PROMPT (line 1) | const RELATED_QUESTIONS_PROMPT = `You are a professional web researcher ...
FILE: lib/agents/prompts/search-mode-prompts.ts
function getQuickModePrompt (line 8) | function getQuickModePrompt(): string {
function getApproachStrategy (line 144) | function getApproachStrategy(): string {
function getAdaptiveModePrompt (line 190) | function getAdaptiveModePrompt(): string {
constant QUICK_MODE_PROMPT (line 316) | const QUICK_MODE_PROMPT = getQuickModePrompt()
FILE: lib/agents/researcher.ts
function wrapSearchToolForQuickMode (line 21) | function wrapSearchToolForQuickMode<
function createResearcher (line 68) | function createResearcher({
function getResearcherTools (line 159) | function getResearcherTools(
FILE: lib/agents/title-generator.ts
type GenerateChatTitleParams (line 6) | interface GenerateChatTitleParams {
function generateChatTitle (line 19) | async function generateChatTitle({
FILE: lib/analytics/track-chat-event.ts
function trackChatEvent (line 31) | async function trackChatEvent(data: ChatEventData): Promise<void> {
FILE: lib/analytics/types.ts
type ChatEventData (line 12) | interface ChatEventData {
type AnalyticsProvider (line 36) | interface AnalyticsProvider {
FILE: lib/analytics/utils.ts
function calculateConversationTurn (line 22) | function calculateConversationTurn(messages: UIMessage[]): number {
FILE: lib/auth/get-current-user.ts
function getCurrentUser (line 5) | async function getCurrentUser() {
function getCurrentUserId (line 18) | async function getCurrentUserId() {
FILE: lib/config/load-models-config.ts
type ModelsConfig (line 8) | interface ModelsConfig {
constant VALID_MODEL_TYPES (line 19) | const VALID_MODEL_TYPES: ModelType[] = ['speed', 'quality']
constant VALID_SEARCH_MODES (line 20) | const VALID_SEARCH_MODES: SearchMode[] = ['quick', 'adaptive']
function validateModelsConfigStructure (line 22) | function validateModelsConfigStructure(
function loadModelsConfig (line 62) | async function loadModelsConfig(): Promise<ModelsConfig> {
function loadModelsConfigSync (line 79) | function loadModelsConfigSync(): ModelsConfig {
function getModelsConfig (line 96) | function getModelsConfig(): ModelsConfig {
FILE: lib/config/model-types.ts
function getModelForModeAndType (line 8) | function getModelForModeAndType(
function getRelatedQuestionsModel (line 17) | function getRelatedQuestionsModel(): Model {
FILE: lib/config/ollama-validator.ts
function getConfiguredOllamaModels (line 15) | async function getConfiguredOllamaModels(): Promise<Model[]> {
function initializeOllamaValidation (line 46) | async function initializeOllamaValidation(): Promise<void> {
function validateOllamaModel (line 154) | function validateOllamaModel(modelId: string): {
function getValidatedOllamaModels (line 190) | function getValidatedOllamaModels(): string[] {
FILE: lib/config/search-modes.ts
type SearchModeConfig (line 7) | interface SearchModeConfig {
constant SEARCH_MODE_CONFIGS (line 16) | const SEARCH_MODE_CONFIGS: SearchModeConfig[] = [
function getSearchModeConfig (line 34) | function getSearchModeConfig(
FILE: lib/constants/index.ts
constant CHAT_ID (line 1) | const CHAT_ID = 'search' as const
FILE: lib/contexts/user-context.tsx
function UserProvider (line 7) | function UserProvider({
function useHasUser (line 17) | function useHasUser() {
FILE: lib/db/__tests__/with-rls.test.ts
function createMockTx (line 17) | function createMockTx(overrides: Partial<TxInstance> = {}): TxInstance {
FILE: lib/db/actions.ts
function createChat (line 23) | async function createChat({
function getChat (line 52) | async function getChat(
function upsertMessage (line 86) | async function upsertMessage(
function loadChat (line 131) | async function loadChat(
function loadChatWithMessages (line 155) | async function loadChatWithMessages(
function deleteMessagesAfter (line 198) | async function deleteMessagesAfter(
function deleteMessagesFromIndex (line 240) | async function deleteMessagesFromIndex(
function getChats (line 275) | async function getChats(userId: string): Promise<Chat[]> {
function getChatsPage (line 288) | async function getChatsPage(
function deleteChat (line 319) | async function deleteChat(
function updateChatVisibility (line 350) | async function updateChatVisibility(
function updateChatTitle (line 374) | async function updateChatTitle(
function createChatWithFirstMessageTransaction (line 394) | async function createChatWithFirstMessageTransaction({
FILE: lib/db/index.ts
type Schema (line 65) | type Schema = typeof schema
FILE: lib/db/schema.ts
constant ID_LENGTH (line 17) | const ID_LENGTH = 191
constant USER_ID_LENGTH (line 18) | const USER_ID_LENGTH = 255
constant VARCHAR_LENGTH (line 19) | const VARCHAR_LENGTH = 256
constant FILENAME_LENGTH (line 20) | const FILENAME_LENGTH = 1024
type Chat (line 70) | type Chat = InferSelectModel<typeof chats>
type Message (line 120) | type Message = InferSelectModel<typeof messages>
type Part (line 258) | type Part = InferSelectModel<typeof parts>
type NewPart (line 259) | type NewPart = typeof parts.$inferInsert
type Feedback (line 300) | type Feedback = InferSelectModel<typeof feedback>
FILE: lib/db/with-rls.ts
type DbInstance (line 6) | type DbInstance = typeof db
type TxInstance (line 7) | type TxInstance = Parameters<Parameters<typeof db.transaction>[0]>[0]
class RLSViolationError (line 12) | class RLSViolationError extends Error {
method constructor (line 13) | constructor(message = 'Row level security policy violation') {
function withRLS (line 39) | async function withRLS<T>(
function withOptionalRLS (line 80) | async function withOptionalRLS<T>(
FILE: lib/firecrawl/client.ts
class FirecrawlClient (line 8) | class FirecrawlClient {
method constructor (line 12) | constructor(apiKey: string) {
method search (line 16) | async search(
method searchImages (line 40) | async searchImages(
method getImagesForQuery (line 58) | async getImagesForQuery(
method getHeaders (line 82) | private getHeaders(): Record<string, string> {
method handleResponse (line 89) | private async handleResponse<T>(response: Response): Promise<T> {
FILE: lib/firecrawl/types.ts
type FirecrawlSource (line 1) | type FirecrawlSource = 'web' | 'news' | 'images'
type FirecrawlSearchOptions (line 3) | type FirecrawlSearchOptions = {
type FirecrawlImageSearchOptions (line 11) | type FirecrawlImageSearchOptions = {
type FirecrawlImageResult (line 17) | type FirecrawlImageResult = {
type FirecrawlWebResult (line 26) | type FirecrawlWebResult = {
type FirecrawlNewsResult (line 34) | type FirecrawlNewsResult = {
type FirecrawlSearchResponseData (line 42) | type FirecrawlSearchResponseData = {
type FirecrawlSearchResponse (line 47) | type FirecrawlSearchResponse = {
type FirecrawlImageSearchResponseData (line 52) | type FirecrawlImageSearchResponseData = {
type FirecrawlImageSearchResponse (line 56) | type FirecrawlImageSearchResponse = {
FILE: lib/hooks/use-copy-to-clipboard.ts
type useCopyToClipboardProps (line 5) | interface useCopyToClipboardProps {
function useCopyToClipboard (line 9) | function useCopyToClipboard({
FILE: lib/hooks/use-media-query.ts
function useMediaQuery (line 10) | function useMediaQuery(query: string): boolean {
FILE: lib/ollama/client.ts
class OllamaClient (line 8) | class OllamaClient {
method constructor (line 11) | constructor(baseUrl: string) {
method getModels (line 19) | async getModels(): Promise<OllamaModel[]> {
method getModelCapabilities (line 46) | async getModelCapabilities(
method isAvailable (line 81) | async isAvailable(): Promise<boolean> {
FILE: lib/ollama/types.ts
type OllamaModel (line 1) | interface OllamaModel {
type OllamaModelCapabilities (line 16) | interface OllamaModelCapabilities {
type OllamaModelsResponse (line 24) | interface OllamaModelsResponse {
type OllamaShowResponse (line 28) | interface OllamaShowResponse {
FILE: lib/rate-limit/chat-limits.ts
constant DAILY_CHAT_LIMIT (line 5) | const DAILY_CHAT_LIMIT = 100
function getSecondsUntilMidnight (line 10) | function getSecondsUntilMidnight(): number {
function getNextMidnightTimestamp (line 20) | function getNextMidnightTimestamp(): number {
function checkOverallChatLimit (line 27) | async function checkOverallChatLimit(userId: string): Promise<{
function checkAndEnforceOverallChatLimit (line 84) | async function checkAndEnforceOverallChatLimit(
FILE: lib/rate-limit/guest-limit.ts
constant DEFAULT_GUEST_DAILY_LIMIT (line 3) | const DEFAULT_GUEST_DAILY_LIMIT = 10
function getGuestDailyLimit (line 5) | function getGuestDailyLimit(): number {
function getSecondsUntilMidnight (line 14) | function getSecondsUntilMidnight(): number {
function getNextMidnightTimestamp (line 21) | function getNextMidnightTimestamp(): number {
function checkGuestLimit (line 28) | async function checkGuestLimit(ip: string): Promise<{
function checkAndEnforceGuestLimit (line 81) | async function checkAndEnforceGuestLimit(
FILE: lib/schema/fetch.tsx
type PartialInquiry (line 14) | type PartialInquiry = DeepPartial<typeof fetchSchema>
FILE: lib/schema/question.ts
function getQuestionSchemaForModel (line 42) | function getQuestionSchemaForModel(fullModel: string) {
FILE: lib/schema/related.tsx
type RelatedQuestion (line 9) | type RelatedQuestion = z.infer<typeof relatedQuestionSchema>
type Related (line 10) | type Related = z.infer<typeof relatedSchema>
FILE: lib/schema/search.tsx
function getSearchSchemaForModel (line 81) | function getSearchSchemaForModel(fullModel: string) {
type PartialInquiry (line 96) | type PartialInquiry = DeepPartial<typeof searchSchema>
FILE: lib/storage/r2-client.ts
constant R2_BUCKET_NAME (line 3) | const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME || 'user-uploads'
constant R2_PUBLIC_URL (line 4) | const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || ''
function getR2Client (line 8) | function getR2Client(): S3Client {
FILE: lib/streaming/create-chat-stream-response.ts
constant DEFAULT_CHAT_TITLE (line 35) | const DEFAULT_CHAT_TITLE = 'Untitled'
function createChatStreamResponse (line 37) | async function createChatStreamResponse(
FILE: lib/streaming/create-ephemeral-chat-stream-response.ts
type EphemeralStreamConfig (line 27) | type EphemeralStreamConfig = Pick<
function createEphemeralChatStreamResponse (line 35) | async function createEphemeralChatStreamResponse(
FILE: lib/streaming/helpers/persist-stream-results.ts
constant DEFAULT_CHAT_TITLE (line 9) | const DEFAULT_CHAT_TITLE = 'Untitled'
function persistStreamResults (line 11) | async function persistStreamResults(
FILE: lib/streaming/helpers/prepare-messages.ts
constant DEFAULT_CHAT_TITLE (line 15) | const DEFAULT_CHAT_TITLE = 'Untitled'
function prepareMessages (line 17) | async function prepareMessages(
FILE: lib/streaming/helpers/stream-related-questions.ts
function streamRelatedQuestions (line 10) | async function streamRelatedQuestions(
FILE: lib/streaming/helpers/strip-reasoning-parts.ts
function stripReasoningParts (line 15) | function stripReasoningParts(messages: UIMessage[]): UIMessage[] {
FILE: lib/streaming/helpers/types.ts
type StreamContext (line 4) | interface StreamContext {
FILE: lib/streaming/types.ts
type BaseStreamConfig (line 7) | interface BaseStreamConfig {
FILE: lib/supabase/client.ts
function createClient (line 3) | function createClient() {
FILE: lib/supabase/middleware.ts
function updateSession (line 5) | async function updateSession(request: NextRequest) {
FILE: lib/supabase/server.ts
function createClient (line 5) | async function createClient() {
FILE: lib/tools/dynamic.ts
function createDynamicTool (line 10) | function createDynamicTool(
function createMCPTool (line 34) | function createMCPTool(
function createCustomTool (line 48) | function createCustomTool(
FILE: lib/tools/fetch.ts
constant CONTENT_CHARACTER_LIMIT (line 6) | const CONTENT_CHARACTER_LIMIT = 50000
constant TITLE_CHARACTER_LIMIT (line 7) | const TITLE_CHARACTER_LIMIT = 100
function fetchRegularData (line 9) | async function fetchRegularData(url: string): Promise<SearchResultsType> {
function fetchJinaReaderData (line 90) | async function fetchJinaReaderData(url: string): Promise<SearchResultsTy...
function fetchTavilyExtractData (line 123) | async function fetchTavilyExtractData(url: string): Promise<SearchResult...
method execute (line 164) | async *execute({ url, type = 'regular' }) {
type FetchUIToolInvocation (line 195) | type FetchUIToolInvocation = UIToolInvocation<typeof fetchTool>
FILE: lib/tools/question.ts
function createQuestionTool (line 8) | function createQuestionTool(fullModel: string) {
FILE: lib/tools/search.ts
function createSearchTool (line 20) | function createSearchTool(fullModel: string) {
type SearchUIToolInvocation (line 173) | type SearchUIToolInvocation = UIToolInvocation<typeof searchTool>
function search (line 175) | async function search(
FILE: lib/tools/search/providers/base.ts
type SearchProvider (line 3) | interface SearchProvider {
method validateApiKey (line 30) | protected validateApiKey(
method validateApiUrl (line 41) | protected validateApiUrl(
FILE: lib/tools/search/providers/brave.ts
type BraveWebResult (line 9) | interface BraveWebResult {
type BraveVideoResult (line 15) | interface BraveVideoResult {
type BraveImageResult (line 30) | interface BraveImageResult {
class BraveSearchProvider (line 46) | class BraveSearchProvider implements SearchProvider {
method constructor (line 49) | constructor() {
method getImageThumbnailUrl (line 53) | private getImageThumbnailUrl(result: BraveImageResult): string {
method search (line 59) | async search(
method searchWeb (line 106) | private async searchWeb(
method searchVideos (line 143) | private async searchVideos(
method searchImages (line 190) | private async searchImages(
FILE: lib/tools/search/providers/exa.ts
class ExaSearchProvider (line 7) | class ExaSearchProvider extends BaseSearchProvider {
method search (line 8) | async search(
FILE: lib/tools/search/providers/firecrawl.ts
class FirecrawlSearchProvider (line 10) | class FirecrawlSearchProvider extends BaseSearchProvider {
method search (line 11) | async search(
FILE: lib/tools/search/providers/index.ts
type SearchProviderType (line 8) | type SearchProviderType =
constant DEFAULT_PROVIDER (line 14) | const DEFAULT_PROVIDER: SearchProviderType = 'tavily'
function createSearchProvider (line 16) | function createSearchProvider(
FILE: lib/tools/search/providers/searxng.ts
class SearXNGSearchProvider (line 10) | class SearXNGSearchProvider extends BaseSearchProvider {
method search (line 11) | async search(
FILE: lib/tools/search/providers/tavily.ts
class TavilySearchProvider (line 6) | class TavilySearchProvider extends BaseSearchProvider {
method search (line 7) | async search(
FILE: lib/tools/todo.ts
type TodoItem (line 18) | type TodoItem = z.infer<typeof todoItemSchema>
function createTodoTools (line 30) | function createTodoTools() {
FILE: lib/types/agent.ts
type ResearcherTools (line 15) | type ResearcherTools = {
type ResearcherAgent (line 23) | type ResearcherAgent = ToolLoopAgent<never, ResearcherTools, never>
type ResearcherUIMessage (line 26) | type ResearcherUIMessage = InferAgentUIMessage<ResearcherAgent>
type ResearcherUITools (line 29) | type ResearcherUITools = InferUITools<ResearcherTools>
type SearchToolInvocation (line 32) | type SearchToolInvocation = UIToolInvocation<ResearcherTools['search']>
type FetchToolInvocation (line 33) | type FetchToolInvocation = UIToolInvocation<ResearcherTools['fetch']>
type QuestionToolInvocation (line 34) | type QuestionToolInvocation = UIToolInvocation<
type TodoWriteToolInvocation (line 37) | type TodoWriteToolInvocation = UIToolInvocation<
type ResearcherToolInvocation (line 42) | type ResearcherToolInvocation =
type ResearcherToolName (line 49) | type ResearcherToolName = keyof ResearcherTools
function isSearchToolInvocation (line 52) | function isSearchToolInvocation(
function isFetchToolInvocation (line 58) | function isFetchToolInvocation(
type ResearcherResponse (line 65) | type ResearcherResponse = Response
type ResearcherRespondOptions (line 68) | type ResearcherRespondOptions = {
FILE: lib/types/ai.ts
type UIMessageMetadata (line 14) | interface UIMessageMetadata {
type UIMessage (line 22) | type UIMessage<
type RelatedQuestionsData (line 28) | interface RelatedQuestionsData {
type UIDataTypes (line 33) | type UIDataTypes = {
type DataRelatedQuestionsPart (line 39) | type DataRelatedQuestionsPart = {
type DataPart (line 45) | type DataPart = DataRelatedQuestionsPart
type UITools (line 50) | type UITools = {
type ToolPart (line 59) | type ToolPart<T extends keyof UITools = keyof UITools> = {
type Part (line 72) | type Part = TextPart | ReasoningPart | ToolPart
FILE: lib/types/dynamic-tools.ts
type MCPClient (line 6) | interface MCPClient {
type DynamicToolConfig (line 14) | interface DynamicToolConfig {
type DynamicToolPart (line 22) | type DynamicToolPart =
type DynamicToolPartBase (line 28) | interface DynamicToolPartBase {
type DynamicToolPartInputStreaming (line 34) | interface DynamicToolPartInputStreaming extends DynamicToolPartBase {
type DynamicToolPartInputAvailable (line 39) | interface DynamicToolPartInputAvailable extends DynamicToolPartBase {
type DynamicToolPartOutputAvailable (line 44) | interface DynamicToolPartOutputAvailable extends DynamicToolPartBase {
type DynamicToolPartOutputError (line 50) | interface DynamicToolPartOutputError extends DynamicToolPartBase {
function isDynamicToolPart (line 57) | function isDynamicToolPart(part: unknown): part is DynamicToolPart {
function isToolCallPart (line 66) | function isToolCallPart(
function isToolTypePart (line 77) | function isToolTypePart(
FILE: lib/types/index.ts
type SearchResults (line 4) | type SearchResults = {
type SearchResultImage (line 16) | type SearchResultImage =
type ExaSearchResults (line 24) | type ExaSearchResults = {
type SerperSearchResults (line 28) | type SerperSearchResults = {
type SearchResultItem (line 37) | type SearchResultItem = {
type ExaSearchResultItem (line 43) | type ExaSearchResultItem = {
type SerperSearchResultItem (line 52) | type SerperSearchResultItem = {
type SearchImageItem (line 64) | type SearchImageItem = {
type SearXNGResult (line 70) | interface SearXNGResult {
type SearXNGResponse (line 79) | interface SearXNGResponse {
type SearXNGImageResult (line 85) | type SearXNGImageResult = string
type SearXNGSearchResults (line 87) | type SearXNGSearchResults = {
type UploadedFile (line 94) | type UploadedFile = {
FILE: lib/types/message-persistence.ts
type Metadata (line 8) | type Metadata = z.infer<typeof metadataSchema>
type DataPart (line 12) | type DataPart = z.infer<typeof dataPartSchema>
type ProviderMetadata (line 15) | type ProviderMetadata = Record<string, any>
type DBMessagePart (line 18) | type DBMessagePart = typeof parts.$inferInsert
type DBMessagePartSelect (line 19) | type DBMessagePartSelect = typeof parts.$inferSelect
type ToolState (line 22) | type ToolState =
type DynamicToolInput (line 29) | type DynamicToolInput = {
type DynamicToolOutput (line 34) | type DynamicToolOutput = unknown
type DynamicToolType (line 37) | type DynamicToolType = 'mcp' | 'dynamic' | 'custom'
type MCPGitHubInput (line 40) | type MCPGitHubInput = {
type DBMessage (line 51) | type DBMessage = {
type PersistableUIMessage (line 59) | type PersistableUIMessage = UIMessage & {
FILE: lib/types/model-type.ts
type ModelType (line 2) | type ModelType = 'speed' | 'quality'
FILE: lib/types/models.ts
type Model (line 1) | interface Model {
FILE: lib/types/search.ts
type SearchMode (line 2) | type SearchMode = 'quick' | 'adaptive'
FILE: lib/utils/__tests__/model-selection.test.ts
type Matrix (line 19) | type Matrix = Record<SearchMode, Partial<Record<ModelType, Model>>>
function setMatrixImplementation (line 51) | function setMatrixImplementation() {
function createCookieStore (line 57) | function createCookieStore(modelType?: string): ReadonlyRequestCookies {
FILE: lib/utils/citation.ts
function isValidUrl (line 8) | function isValidUrl(url: string): boolean {
function extractCitationMaps (line 21) | function extractCitationMaps(
function extractCitationMapsFromMessages (line 51) | function extractCitationMapsFromMessages(
function processCitations (line 72) | function processCitations(
FILE: lib/utils/context-window.ts
type ModelContextInfo (line 6) | interface ModelContextInfo {
constant MODEL_CONTEXT_WINDOWS (line 12) | const MODEL_CONTEXT_WINDOWS: Record<string, ModelContextInfo> = {
constant DEFAULT_CONTEXT_WINDOW (line 37) | const DEFAULT_CONTEXT_WINDOW = 16384
constant DEFAULT_OUTPUT_TOKENS (line 38) | const DEFAULT_OUTPUT_TOKENS = 4096
constant SAFETY_BUFFER_RATIO (line 41) | const SAFETY_BUFFER_RATIO = 0.1
constant MODEL_TO_ENCODING (line 48) | const MODEL_TO_ENCODING: Record<string, TiktokenEncoding> = {
function getModelContextInfo (line 68) | function getModelContextInfo(modelId: string): ModelContextInfo {
function getMaxAllowedTokens (line 81) | function getMaxAllowedTokens(model: Model): number {
function extractTextContent (line 98) | function extractTextContent(content: ModelMessage['content']): string {
function getEncoder (line 125) | function getEncoder(modelId: string) {
function estimateTokenCount (line 151) | function estimateTokenCount(
function truncateMessages (line 189) | function truncateMessages(
function shouldTruncateMessages (line 290) | function shouldTruncateMessages(
FILE: lib/utils/cookies.ts
function setCookie (line 1) | function setCookie(name: string, value: string, days = 30) {
function getCookie (line 10) | function getCookie(name: string): string | null {
function deleteCookie (line 23) | function deleteCookie(name: string) {
FILE: lib/utils/index.ts
function generateUUID (line 7) | function generateUUID(): string {
function cn (line 16) | function cn(...inputs: ClassValue[]) {
function sanitizeUrl (line 25) | function sanitizeUrl(url: string): string {
function createModelId (line 29) | function createModelId(model: Model): string {
function getDefaultModelId (line 33) | function getDefaultModelId(models: Model[]): string {
FILE: lib/utils/message-mapping.ts
type TextUIPart (line 16) | type TextUIPart = { type: 'text'; text: string; providerMetadata?: any }
type ReasoningUIPart (line 17) | type ReasoningUIPart = {
type FileUIPart (line 22) | type FileUIPart = {
type SourceUrlUIPart (line 28) | type SourceUrlUIPart = {
type SourceDocumentUIPart (line 34) | type SourceDocumentUIPart = {
type ToolCallPart (line 43) | type ToolCallPart = {
type ToolResultPart (line 49) | type ToolResultPart = {
type DataPart (line 55) | type DataPart = { type: string; [key: string]: any }
type UIMessagePart (line 57) | type UIMessagePart =
function isToolCallPart (line 68) | function isToolCallPart(part: any): part is ToolCallPart {
function isToolResultPart (line 77) | function isToolResultPart(part: any): part is ToolResultPart {
type ExtendedToolPart (line 86) | type ExtendedToolPart = {
function isExtendedToolPart (line 95) | function isExtendedToolPart(part: any): part is ExtendedToolPart {
function createToolPartMapping (line 105) | function createToolPartMapping(
function mapUIMessagePartsToDBParts (line 127) | function mapUIMessagePartsToDBParts(
function mapDBPartToUIMessagePart (line 344) | function mapDBPartToUIMessagePart(
function getToolNameFromType (line 671) | function getToolNameFromType(toolName: string): string {
function getToolNameFromCallId (line 693) | function getToolNameFromCallId(
function getOriginalToolName (line 713) | function getOriginalToolName(dbToolName: string): string {
function mapUIMessageToDBMessage (line 729) | function mapUIMessageToDBMessage(
function buildUIMessageFromDB (line 748) | function buildUIMessageFromDB(
FILE: lib/utils/message-utils.ts
type DatabaseMessageInput (line 6) | interface DatabaseMessageInput {
function convertMessageForDB (line 16) | function convertMessageForDB(
function convertMessagesForDB (line 55) | function convertMessagesForDB(
function extractTitleFromMessage (line 67) | function extractTitleFromMessage(
function getTextFromParts (line 94) | function getTextFromParts(parts?: UIMessage['parts']): string {
function mergeUIMessages (line 109) | function mergeUIMessages(
function hasToolCalls (line 124) | function hasToolCalls(message: UIMessage | null): boolean {
FILE: lib/utils/model-selection.ts
constant DEFAULT_MODEL (line 9) | const DEFAULT_MODEL: Model = {
constant VALID_MODEL_TYPES (line 22) | const VALID_MODEL_TYPES: ModelType[] = ['speed', 'quality']
constant MODE_FALLBACK_ORDER (line 23) | const MODE_FALLBACK_ORDER: SearchMode[] = ['quick', 'adaptive']
type ModelSelectionParams (line 25) | interface ModelSelectionParams {
function resolveModelForModeAndType (line 30) | function resolveModelForModeAndType(
function selectModel (line 66) | function selectModel({
FILE: lib/utils/perf-logging.ts
function perfLog (line 5) | function perfLog(message: string) {
function perfTime (line 11) | function perfTime(label: string, startTime: number) {
FILE: lib/utils/perf-tracking.ts
function resetAuthCallCount (line 17) | function resetAuthCallCount() {
function incrementAuthCallCount (line 21) | function incrementAuthCallCount() {
function resetDbOperationCount (line 26) | function resetDbOperationCount() {
function incrementDbOperationCount (line 30) | function incrementDbOperationCount() {
function resetAllCounters (line 35) | function resetAllCounters() {
FILE: lib/utils/registry.ts
function getModel (line 31) | function getModel(model: string): LanguageModel {
function isProviderEnabled (line 37) | function isProviderEnabled(providerId: string): boolean {
FILE: lib/utils/retry.ts
type RetryOptions (line 3) | interface RetryOptions {
function retryWithBackoff (line 11) | async function retryWithBackoff<T>(
function retryDatabaseOperation (line 54) | async function retryDatabaseOperation<T>(
FILE: lib/utils/search-config.ts
function isGeneralSearchProviderAvailable (line 10) | function isGeneralSearchProviderAvailable(): boolean {
function getGeneralSearchProviderName (line 17) | function getGeneralSearchProviderName(): string {
function supportsMultimediaContentTypes (line 27) | function supportsMultimediaContentTypes(): boolean {
function getSearchTypeDescription (line 35) | function getSearchTypeDescription(): string {
function getSearchToolDescription (line 49) | function getSearchToolDescription(): string {
function getContentTypesGuidance (line 62) | function getContentTypesGuidance(): string {
function getSearchStrategyGuidance (line 90) | function getSearchStrategyGuidance(): string {
function getGeneralSearchProviderType (line 113) | function getGeneralSearchProviderType(): 'brave' | null {
FILE: lib/utils/telemetry.ts
function isTracingEnabled (line 5) | function isTracingEnabled(): boolean {
FILE: lib/utils/url.ts
function getBaseUrlFromHeaders (line 7) | async function getBaseUrlFromHeaders(): Promise<URL> {
function getBaseUrl (line 39) | async function getBaseUrl(): Promise<URL> {
function getBaseUrlString (line 65) | async function getBaseUrlString(): Promise<string> {
FILE: proxy.ts
function proxy (line 5) | async function proxy(request: NextRequest) {
FILE: scripts/chat-cli.ts
constant DEFAULT_MESSAGE (line 11) | const DEFAULT_MESSAGE = 'Hello, how are you?'
type ChatApiConfig (line 13) | interface ChatApiConfig {
type UIMessage (line 23) | interface UIMessage {
type ChatPayload (line 35) | interface ChatPayload {
class ChatApiTester (line 43) | class ChatApiTester {
method validateUrl (line 46) | private validateUrl(url?: string): string | undefined {
method constructor (line 85) | constructor(config: Partial<ChatApiConfig> = {}) {
method generateId (line 98) | private generateId(): string {
method createUserMessage (line 102) | private createUserMessage(text: string, messageId?: string): UIMessage {
method loadCookiesFromEnv (line 120) | private loadCookiesFromEnv(): string | undefined {
method sendMessage (line 128) | async sendMessage(message?: string): Promise<void> {
function parseArgs (line 312) | function parseArgs(): Partial<ChatApiConfig> {
function main (line 427) | async function main() {
FILE: scripts/test-cache-performance.ts
constant API_URL (line 7) | const API_URL = process.env.API_URL || 'http://localhost:3001/api/chat'
constant COOKIES (line 8) | const COOKIES = process.env.MORPHIC_COOKIES
function measureRequest (line 10) | async function measureRequest(
function runPerformanceTests (line 61) | async function runPerformanceTests() {
Condensed preview — 295 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,109K chars).
[
{
"path": ".eslintrc.json",
"chars": 906,
"preview": "{\n \"extends\": \"next/core-web-vitals\",\n \"plugins\": [\"simple-import-sort\"],\n \"rules\": {\n \"simple-import-sort/imports"
},
{
"path": ".github/FUNDING.yml",
"chars": 62,
"preview": "# These are supported funding model platforms\n\ngithub: miurla\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 1957,
"preview": "name: 🐞 Bug\ndescription: File a bug/issue\ntitle: '[BUG] <title>'\nlabels: ['Bug', 'Needs Triage']\nbody:\n - type: checkbo"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.yml",
"chars": 1007,
"preview": "name: ✨ Feature Request\ndescription: Propose a new feature for Morphic.\nlabels: []\nbody:\n - type: markdown\n attribut"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2197,
"preview": "name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\njobs:\n lint:\n runs-on: ubuntu-lates"
},
{
"path": ".github/workflows/docker-build.yml",
"chars": 999,
"preview": "name: Docker Build and Push\n\non:\n push:\n branches: [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n permissions"
},
{
"path": ".github/workflows/release.yml",
"chars": 3448,
"preview": "name: Release\n\non:\n push:\n tags:\n - 'v*' # e.g. v1.0.0, v1.0.0-beta.5\n\nconcurrency:\n group: release-${{ github"
},
{
"path": ".gitignore",
"chars": 415,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".mcp.json",
"chars": 162,
"preview": "{\n \"mcpServers\": {\n \"next-devtools\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"next-devtools-"
},
{
"path": ".prettierignore",
"chars": 322,
"preview": "# Dependencies\nnode_modules/\n.next/\nout/\nbuild/\ndist/\n\n# Environment\n.env*\n\n# Generated files\n*.lock\nbun.lock\npackage-lo"
},
{
"path": ".vscode/settings.json",
"chars": 205,
"preview": "{\n \"editor.formatOnSave\": true,\n \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n \"cSpell.words\": [\"openai\", \"Ta"
},
{
"path": "AGENTS.md",
"chars": 8420,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 2445,
"preview": "# Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity"
},
{
"path": "CONTRIBUTING.md",
"chars": 1281,
"preview": "# Contributing to Morphic\n\nThank you for your interest in contributing to Morphic! This document provides guidelines and"
},
{
"path": "Dockerfile",
"chars": 1374,
"preview": "# Build stage - Use Node for Next.js 16 compatibility (Bun lacks worker_threads support on arm64)\nFROM node:22-slim AS b"
},
{
"path": "LICENSE",
"chars": 10933,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 7317,
"preview": "<div align=\"center\">\n\n# Morphic\n\nAn AI-powered search engine with a generative UI.\n\n[ =>"
},
{
"path": "app/api/feedback/route.ts",
"chars": 2181,
"preview": "import { Langfuse } from 'langfuse'\n\nimport { updateMessageFeedback } from '@/lib/actions/feedback'\nimport { createClien"
},
{
"path": "app/api/upload/route.ts",
"chars": 2596,
"preview": "import { NextRequest, NextResponse } from 'next/server'\n\nimport { PutObjectCommand } from '@aws-sdk/client-s3'\n\nimport {"
},
{
"path": "app/auth/confirm/route.ts",
"chars": 993,
"preview": "import { redirect } from 'next/navigation'\nimport { type NextRequest } from 'next/server'\n\nimport { type EmailOtpType } "
},
{
"path": "app/auth/error/page.tsx",
"chars": 1037,
"preview": "import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\n\nexport default async function Page({\n "
},
{
"path": "app/auth/forgot-password/page.tsx",
"chars": 301,
"preview": "import { ForgotPasswordForm } from '@/components/forgot-password-form'\n\nexport default function Page() {\n return (\n "
},
{
"path": "app/auth/login/page.tsx",
"chars": 273,
"preview": "import { LoginForm } from '@/components/login-form'\n\nexport default function Page() {\n return (\n <div className=\"fle"
},
{
"path": "app/auth/oauth/route.ts",
"chars": 1226,
"preview": "import { NextResponse } from 'next/server'\n\n// The client you created from the Server-Side Auth instructions\nimport { cr"
},
{
"path": "app/auth/sign-up/page.tsx",
"chars": 277,
"preview": "import { SignUpForm } from '@/components/sign-up-form'\n\nexport default function Page() {\n return (\n <div className=\""
},
{
"path": "app/auth/sign-up-success/page.tsx",
"chars": 913,
"preview": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle\n} from '@/components/ui/card'\n\nexport defau"
},
{
"path": "app/auth/update-password/page.tsx",
"chars": 301,
"preview": "import { UpdatePasswordForm } from '@/components/update-password-form'\n\nexport default function Page() {\n return (\n "
},
{
"path": "app/globals.css",
"chars": 10653,
"preview": "@import 'tailwindcss';\n@source \"../node_modules/streamdown/dist/index.js\";\n\n@custom-variant dark (&:is(.dark *));\n\n:root"
},
{
"path": "app/layout.tsx",
"chars": 2606,
"preview": "import type { Metadata, Viewport } from 'next'\nimport { Inter as FontSans } from 'next/font/google'\n\nimport { Analytics "
},
{
"path": "app/page.tsx",
"chars": 225,
"preview": "import { getCurrentUserId } from '@/lib/auth/get-current-user'\n\nimport { Chat } from '@/components/chat'\n\nexport default"
},
{
"path": "app/search/[id]/page.tsx",
"chars": 1052,
"preview": "import { notFound, redirect } from 'next/navigation'\n\nimport { UIMessage } from 'ai'\n\nimport { loadChat } from '@/lib/ac"
},
{
"path": "app/search/loading.tsx",
"chars": 304,
"preview": "'use client'\n\nimport { DefaultSkeleton } from '../../components/default-skeleton'\n\nexport default function Loading() {\n "
},
{
"path": "app/search/page.tsx",
"chars": 520,
"preview": "import { redirect } from 'next/navigation'\n\nimport { getCurrentUserId } from '@/lib/auth/get-current-user'\nimport { gene"
},
{
"path": "components/__tests__/research-process-section.test.tsx",
"chars": 12282,
"preview": "import React from 'react'\n\nimport type { ReasoningPart } from '@ai-sdk/provider-utils'\nimport { fireEvent, render, scree"
},
{
"path": "components/action-buttons.tsx",
"chars": 6316,
"preview": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\n\nimport {\n FileText,\n HelpCircle,\n LucideIcon,\n Ne"
},
{
"path": "components/answer-section.tsx",
"chars": 2213,
"preview": "'use client'\n\nimport { UseChatHelpers } from '@ai-sdk/react'\nimport { ChatRequestOptions } from 'ai'\n\nimport type { Sear"
},
{
"path": "components/app-sidebar.tsx",
"chars": 1581,
"preview": "import { Suspense } from 'react'\nimport Link from 'next/link'\n\nimport { Plus } from 'lucide-react'\n\nimport { cn } from '"
},
{
"path": "components/artifact/artifact-content.tsx",
"chars": 980,
"preview": "'use client'\n\nimport { Part, ToolPart } from '@/lib/types/ai'\n\nimport { ReasoningContent } from './reasoning-content'\nim"
},
{
"path": "components/artifact/artifact-context.tsx",
"chars": 2138,
"preview": "'use client'\n\nimport {\n createContext,\n ReactNode,\n useCallback,\n useContext,\n useEffect,\n useReducer\n} from 'reac"
},
{
"path": "components/artifact/artifact-root.tsx",
"chars": 376,
"preview": "'use client'\n\nimport { ReactNode } from 'react'\n\nimport { ArtifactProvider } from './artifact-context'\nimport { ChatArti"
},
{
"path": "components/artifact/chat-artifact-container.tsx",
"chars": 5997,
"preview": "'use client'\n\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { useHasUser } from '@/lib"
},
{
"path": "components/artifact/reasoning-content.tsx",
"chars": 415,
"preview": "'use client'\n\nimport remarkGfm from 'remark-gfm'\nimport { Streamdown } from 'streamdown'\n\nimport { cn } from '@/lib/util"
},
{
"path": "components/artifact/search-artifact-content.tsx",
"chars": 2175,
"preview": "'use client'\n\nimport type { SearchResults as TypeSearchResults } from '@/lib/types'\nimport type { ToolPart } from '@/lib"
},
{
"path": "components/artifact/todo-invocation-content.tsx",
"chars": 925,
"preview": "'use client'\n\nimport type { ToolPart } from '@/lib/types/ai'\n\nimport TodoListContent from '../todo-list-content'\n\ninterf"
},
{
"path": "components/artifact/tool-invocation-content.tsx",
"chars": 438,
"preview": "'use client'\n\nimport type { ToolPart } from '@/lib/types/ai'\n\nimport { SearchArtifactContent } from '@/components/artifa"
},
{
"path": "components/attachment-preview.tsx",
"chars": 1476,
"preview": "'use client'\n\nimport React from 'react'\n\ninterface Attachment {\n name: string | undefined\n url: string\n contentType: "
},
{
"path": "components/auth-modal.tsx",
"chars": 1435,
"preview": "'use client'\n\nimport Link from 'next/link'\n\nimport { Button } from '@/components/ui/button'\nimport {\n Dialog,\n DialogC"
},
{
"path": "components/chat-error.tsx",
"chars": 951,
"preview": "import { AlertCircle } from 'lucide-react'\n\nimport { Card } from '@/components/ui/card'\n\ninterface ChatErrorProps {\n er"
},
{
"path": "components/chat-messages.tsx",
"chars": 8108,
"preview": "'use client'\n\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\nimport { UseChatHelpers } from '@ai-sdk/reac"
},
{
"path": "components/chat-panel.tsx",
"chars": 12126,
"preview": "'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport Textarea from 'react-textarea-auto"
},
{
"path": "components/chat-share.tsx",
"chars": 2794,
"preview": "'use client'\n\nimport { useState, useTransition } from 'react'\n\nimport { Share } from 'lucide-react'\nimport { toast } fro"
},
{
"path": "components/chat.tsx",
"chars": 15427,
"preview": "'use client'\n\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { useRouter } from 'next/navigation'\n\n"
},
{
"path": "components/citation-context.tsx",
"chars": 730,
"preview": "'use client'\n\nimport { createContext, ReactNode, useContext } from 'react'\n\nimport type { SearchResultItem } from '@/lib"
},
{
"path": "components/citation-link.tsx",
"chars": 3970,
"preview": "'use client'\n\nimport { memo, useState } from 'react'\nimport Link from 'next/link'\n\nimport type { SearchResultItem } from"
},
{
"path": "components/collapsible-message.tsx",
"chars": 5888,
"preview": "import { ChevronDown } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport {\n Collapsible,\n CollapsibleConte"
},
{
"path": "components/current-user-avatar.tsx",
"chars": 815,
"preview": "'use client'\n\nimport { User2 } from 'lucide-react'\n\nimport { useCurrentUserImage } from '@/hooks/use-current-user-image'"
},
{
"path": "components/custom-link.tsx",
"chars": 1563,
"preview": "import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react'\n\nimport type { SearchResultItem } from '@/lib/types'\nimp"
},
{
"path": "components/data-section.tsx",
"chars": 562,
"preview": "'use client'\n\nimport React from 'react'\n\nimport type { DataPart } from '@/lib/types/ai'\n\nimport { RelatedQuestions } fro"
},
{
"path": "components/default-skeleton.tsx",
"chars": 621,
"preview": "'use client'\n\nimport { Skeleton } from './ui/skeleton'\n\nexport const DefaultSkeleton = () => {\n return (\n <div class"
},
{
"path": "components/drag-overlay.tsx",
"chars": 605,
"preview": "'use client'\nimport { UploadCloud } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nexport function DragOverlay({"
},
{
"path": "components/dynamic-tool-display.tsx",
"chars": 4006,
"preview": "'use client'\n\nimport React from 'react'\n\n// This matches the structure from AI SDK v5\ntype DynamicToolPart =\n | {\n "
},
{
"path": "components/error-modal.tsx",
"chars": 4309,
"preview": "'use client'\n\nimport Link from 'next/link'\n\nimport { AlertCircle, Clock, RefreshCw } from 'lucide-react'\n\nimport { Butto"
},
{
"path": "components/external-link-items.tsx",
"chars": 884,
"preview": "'use client'\n\nimport { SiDiscord, SiGithub, SiX } from 'react-icons/si'\nimport Link from 'next/link'\n\nimport { DropdownM"
},
{
"path": "components/feedback-modal.tsx",
"chars": 4135,
"preview": "'use client'\n\nimport { useState, useTransition } from 'react'\n\nimport { Frown, Meh, Smile } from 'lucide-react'\nimport {"
},
{
"path": "components/fetch-section.tsx",
"chars": 4827,
"preview": "'use client'\n\nimport { UseChatHelpers } from '@ai-sdk/react'\nimport { AlertCircle, Check, ExternalLink, Globe } from 'lu"
},
{
"path": "components/file-upload-button.tsx",
"chars": 2237,
"preview": "'use client'\n\nimport { useRef, useState } from 'react'\n\nimport { Paperclip } from 'lucide-react'\nimport { toast } from '"
},
{
"path": "components/forgot-password-form.tsx",
"chars": 3544,
"preview": "'use client'\n\nimport { useState } from 'react'\nimport Link from 'next/link'\n\nimport { createClient } from '@/lib/supabas"
},
{
"path": "components/guest-menu.tsx",
"chars": 1834,
"preview": "'use client'\n\nimport Link from 'next/link'\n\nimport {\n Link2,\n LogIn,\n Palette,\n Settings2 // Or EllipsisVertical, et"
},
{
"path": "components/header.tsx",
"chars": 1858,
"preview": "'use client'\n\n// import Link from 'next/link' // No longer needed directly here for Sign In button\nimport React, { useSt"
},
{
"path": "components/inspector/inspector-drawer.tsx",
"chars": 1391,
"preview": "'use client'\n\nimport { VisuallyHidden } from '@radix-ui/react-visually-hidden'\n\nimport { useMediaQuery } from '@/lib/hoo"
},
{
"path": "components/inspector/inspector-panel.tsx",
"chars": 2512,
"preview": "'use client'\n\nimport {\n LightbulbIcon,\n ListTodo,\n MessageSquare,\n Minimize2,\n Search\n} from 'lucide-react'\n\nimport"
},
{
"path": "components/login-form.tsx",
"chars": 5125,
"preview": "'use client'\n\nimport { useState } from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\n"
},
{
"path": "components/message-actions.tsx",
"chars": 4354,
"preview": "'use client'\n\nimport { useMemo, useState } from 'react'\n\nimport { UseChatHelpers } from '@ai-sdk/react'\nimport { Copy, T"
},
{
"path": "components/message.tsx",
"chars": 1515,
"preview": "'use client'\n\nimport rehypeExternalLinks from 'rehype-external-links'\nimport rehypeKatex from 'rehype-katex'\nimport rema"
},
{
"path": "components/model-type-selector.tsx",
"chars": 2656,
"preview": "'use client'\n\nimport { useEffect, useState } from 'react'\n\nimport { Check, ChevronDown } from 'lucide-react'\n\nimport { M"
},
{
"path": "components/process-header.tsx",
"chars": 1025,
"preview": "'use client'\n\nimport type { ReactNode } from 'react'\n\nimport { cn } from '@/lib/utils'\n\nexport type ProcessHeaderProps ="
},
{
"path": "components/process-rail.tsx",
"chars": 1077,
"preview": "import { cn } from '@/lib/utils'\n\ninterface ProcessRailProps {\n isFirst: boolean\n isLast: boolean\n isSubStep?: boolea"
},
{
"path": "components/question-confirmation.tsx",
"chars": 6554,
"preview": "'use client'\n\nimport { useState } from 'react'\n\nimport { ArrowRight, Check, SkipForward } from 'lucide-react'\n\nimport ty"
},
{
"path": "components/reasoning-section.tsx",
"chars": 5064,
"preview": "'use client'\n\nimport { useEffect, useState } from 'react'\n\nimport type { ReasoningPart } from '@ai-sdk/provider-utils'\n\n"
},
{
"path": "components/related-questions.tsx",
"chars": 2970,
"preview": "'use client'\n\nimport React from 'react'\n\nimport { ArrowRight } from 'lucide-react'\n\nimport type { RelatedQuestionsData }"
},
{
"path": "components/render-message.tsx",
"chars": 5481,
"preview": "import { UseChatHelpers } from '@ai-sdk/react'\n\nimport type { SearchResultItem } from '@/lib/types'\nimport type {\n UIDa"
},
{
"path": "components/research-process-section.tsx",
"chars": 12327,
"preview": "'use client'\n\nimport { useCallback, useState } from 'react'\n\nimport type { ReasoningPart } from '@ai-sdk/provider-utils'"
},
{
"path": "components/retry-button.tsx",
"chars": 609,
"preview": "'use client'\n\nimport { RotateCcw } from 'lucide-react'\n\nimport { Button } from './ui/button'\n\ninterface RetryButtonProps"
},
{
"path": "components/search-mode-selector.tsx",
"chars": 7539,
"preview": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\n\nimport { Check, ChevronDown } from 'lucide-react'\n\nim"
},
{
"path": "components/search-results-image.tsx",
"chars": 10381,
"preview": "/* eslint-disable @next/next/no-img-element */\n'use client'\n\nimport {\n type Dispatch,\n type SetStateAction,\n useCallb"
},
{
"path": "components/search-results.tsx",
"chars": 4538,
"preview": "'use client'\n\nimport { useState } from 'react'\nimport Link from 'next/link'\n\nimport { SearchResultItem } from '@/lib/typ"
},
{
"path": "components/search-section.tsx",
"chars": 6116,
"preview": "'use client'\n\nimport { UseChatHelpers } from '@ai-sdk/react'\nimport { Check, Search as SearchIcon } from 'lucide-react'\n"
},
{
"path": "components/section.tsx",
"chars": 3043,
"preview": "'use client'\n\nimport React from 'react'\n\nimport {\n BookCheck,\n Check,\n File,\n FileText,\n Film,\n Image,\n MessageCi"
},
{
"path": "components/sidebar/chat-history-client.tsx",
"chars": 4246,
"preview": "'use client'\n\nimport { useCallback, useEffect, useRef, useState, useTransition } from 'react'\n\nimport { toast } from 'so"
},
{
"path": "components/sidebar/chat-history-section.tsx",
"chars": 137,
"preview": "import { ChatHistoryClient } from './chat-history-client'\n\nexport async function ChatHistorySection() {\n return <ChatHi"
},
{
"path": "components/sidebar/chat-history-skeleton.tsx",
"chars": 368,
"preview": "import {\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuSkeleton\n} from '@/components/ui/sidebar'\n\nexport function ChatH"
},
{
"path": "components/sidebar/chat-menu-item.tsx",
"chars": 5982,
"preview": "'use client'\n\nimport { useCallback, useState, useTransition } from 'react'\nimport Link from 'next/link'\nimport { usePath"
},
{
"path": "components/sidebar/clear-history-action.tsx",
"chars": 3693,
"preview": "'use client'\n\nimport { useCallback, useState, useTransition } from 'react'\nimport { useRouter } from 'next/navigation'\n\n"
},
{
"path": "components/sign-up-form.tsx",
"chars": 4424,
"preview": "'use client'\nimport { useState } from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\n\n"
},
{
"path": "components/source-favicons.tsx",
"chars": 1439,
"preview": "import Image from 'next/image'\n\nimport type { SearchResultItem } from '@/lib/types'\nimport { cn } from '@/lib/utils'\n\nin"
},
{
"path": "components/theme-menu-items.tsx",
"chars": 735,
"preview": "'use client'\n\nimport { useTheme } from 'next-themes'\n\nimport { Laptop, Moon, Sun } from 'lucide-react'\n\nimport { Dropdow"
},
{
"path": "components/theme-provider.tsx",
"chars": 327,
"preview": "'use client'\n\nimport * as React from 'react'\nimport { ThemeProvider as NextThemesProvider } from 'next-themes'\nimport { "
},
{
"path": "components/todo-list-content.tsx",
"chars": 2931,
"preview": "'use client'\n\nimport { AlertCircle, Check } from 'lucide-react'\n\nimport type { TodoItem } from '@/lib/types/ai'\nimport {"
},
{
"path": "components/tool-badge.tsx",
"chars": 806,
"preview": "import React from 'react'\n\nimport { Link, Search } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Badge"
},
{
"path": "components/tool-section.tsx",
"chars": 3071,
"preview": "'use client'\n\nimport { UseChatHelpers } from '@ai-sdk/react'\n\nimport type { ToolPart, UIDataTypes, UIMessage, UITools } "
},
{
"path": "components/tool-todo-display.tsx",
"chars": 4818,
"preview": "import { Check, ListTodo } from 'lucide-react'\n\nimport { Part, TodoItem } from '@/lib/types/ai'\nimport { cn } from '@/li"
},
{
"path": "components/ui/alert-dialog.tsx",
"chars": 4435,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'\n\nimpo"
},
{
"path": "components/ui/animated-logo.tsx",
"chars": 635,
"preview": "'use client'\n\nimport { cn } from '@/lib/utils'\n\nexport function AnimatedLogo({\n className,\n ...props\n}: React.Componen"
},
{
"path": "components/ui/avatar.tsx",
"chars": 1426,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as AvatarPrimitive from '@radix-ui/react-avatar'\n\nimport { cn } f"
},
{
"path": "components/ui/badge.tsx",
"chars": 1127,
"preview": "import * as React from 'react'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@"
},
{
"path": "components/ui/button.tsx",
"chars": 1839,
"preview": "import * as React from 'react'\n\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'clas"
},
{
"path": "components/ui/card.tsx",
"chars": 1883,
"preview": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils/index'\n\nconst Card = React.forwardRef<\n HTMLDivElement,"
},
{
"path": "components/ui/carousel.tsx",
"chars": 6222,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport useEmblaCarousel, {\n type UseEmblaCarouselType\n} from 'embla-carou"
},
{
"path": "components/ui/checkbox.tsx",
"chars": 1073,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox'\nimport { Che"
},
{
"path": "components/ui/collapsible.tsx",
"chars": 329,
"preview": "'use client'\n\nimport * as CollapsiblePrimitive from '@radix-ui/react-collapsible'\n\nconst Collapsible = CollapsiblePrimit"
},
{
"path": "components/ui/command.tsx",
"chars": 4904,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport { type DialogProps } from '@radix-ui/react-dialog'\nimport { Command"
},
{
"path": "components/ui/dialog.tsx",
"chars": 3851,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { X } fro"
},
{
"path": "components/ui/drawer.tsx",
"chars": 3027,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport { Drawer as DrawerPrimitive } from 'vaul'\n\nimport { cn } from '@/li"
},
{
"path": "components/ui/dropdown-menu.tsx",
"chars": 8377,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'\nimp"
},
{
"path": "components/ui/hover-card.tsx",
"chars": 1526,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as HoverCardPrimitive from '@radix-ui/react-hover-card'\n\nimport {"
},
{
"path": "components/ui/icons.tsx",
"chars": 4638,
"preview": "'use client'\n\nimport { useEffect, useRef } from 'react'\n\nimport { cn } from '@/lib/utils'\n\nfunction IconLogo({ className"
},
{
"path": "components/ui/index.ts",
"chars": 84,
"preview": "export * from './button'\nexport * from './tooltip'\nexport * from './tooltip-button'\n"
},
{
"path": "components/ui/input.tsx",
"chars": 832,
"preview": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils/index'\n\nexport interface InputProps\n extends React.Inpu"
},
{
"path": "components/ui/label.tsx",
"chars": 731,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as LabelPrimitive from '@radix-ui/react-label'\nimport { cva, type"
},
{
"path": "components/ui/password-input.tsx",
"chars": 1105,
"preview": "import * as React from 'react'\nimport { useState } from 'react'\n\nimport { Eye, EyeOff } from 'lucide-react'\n\nimport { In"
},
{
"path": "components/ui/popover.tsx",
"chars": 1247,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as PopoverPrimitive from '@radix-ui/react-popover'\n\nimport { cn }"
},
{
"path": "components/ui/select.tsx",
"chars": 5622,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as SelectPrimitive from '@radix-ui/react-select'\nimport { Check, "
},
{
"path": "components/ui/separator.tsx",
"chars": 771,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as SeparatorPrimitive from '@radix-ui/react-separator'\n\nimport { "
},
{
"path": "components/ui/sheet.tsx",
"chars": 4285,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as SheetPrimitive from '@radix-ui/react-dialog'\nimport { cva, typ"
},
{
"path": "components/ui/sidebar.tsx",
"chars": 24448,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, VariantProps } f"
},
{
"path": "components/ui/skeleton.tsx",
"chars": 267,
"preview": "import { cn } from '@/lib/utils/index'\n\nfunction Skeleton({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivEleme"
},
{
"path": "components/ui/slider.tsx",
"chars": 1094,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as SliderPrimitive from '@radix-ui/react-slider'\n\nimport { cn } f"
},
{
"path": "components/ui/sonner.tsx",
"chars": 893,
"preview": "'use client'\n\nimport { useTheme } from 'next-themes'\n\nimport { Toaster as Sonner } from 'sonner'\n\ntype ToasterProps = Re"
},
{
"path": "components/ui/spinner.tsx",
"chars": 884,
"preview": "// Based on: https://github.com/vercel/ai/blob/main/examples/next-ai-rsc/components/llm-stocks/spinner.tsx\n\nimport { cn "
},
{
"path": "components/ui/status-indicator.tsx",
"chars": 485,
"preview": "import { ReactNode } from 'react'\n\nimport { LucideIcon } from 'lucide-react'\n\ninterface StatusIndicatorProps {\n icon: L"
},
{
"path": "components/ui/switch.tsx",
"chars": 1156,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as SwitchPrimitives from '@radix-ui/react-switch'\n\nimport { cn } "
},
{
"path": "components/ui/textarea.tsx",
"chars": 774,
"preview": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nexport interface TextareaProps\n extends React.Textare"
},
{
"path": "components/ui/toggle.tsx",
"chars": 1599,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as TogglePrimitive from '@radix-ui/react-toggle'\nimport { cva, ty"
},
{
"path": "components/ui/tooltip-button.tsx",
"chars": 1197,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport { Button, ButtonProps } from '@/components/ui/button'\nimport {\n To"
},
{
"path": "components/ui/tooltip.tsx",
"chars": 1166,
"preview": "'use client'\n\nimport * as React from 'react'\n\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip'\n\nimport { cn }"
},
{
"path": "components/update-password-form.tsx",
"chars": 2464,
"preview": "'use client'\n\nimport { useState } from 'react'\nimport { useRouter } from 'next/navigation'\n\nimport { createClient } from"
},
{
"path": "components/uploaded-file-list.tsx",
"chars": 2155,
"preview": "'use client'\n\nimport React from 'react'\nimport Image from 'next/image'\n\nimport { Loader2, X } from 'lucide-react'\n\nimpor"
},
{
"path": "components/user-file-section.tsx",
"chars": 325,
"preview": "import React from 'react'\n\nimport { AttachmentPreview } from './attachment-preview'\n\ninterface UserFileSectionProps {\n "
},
{
"path": "components/user-menu.tsx",
"chars": 3292,
"preview": "'use client'\n\nimport { useRouter } from 'next/navigation'\n\nimport { User } from '@supabase/supabase-js'\nimport { Link2, "
},
{
"path": "components/user-text-section.tsx",
"chars": 4100,
"preview": "'use client'\n\nimport React, { useEffect, useRef, useState } from 'react'\nimport TextareaAutosize from 'react-textarea-au"
},
{
"path": "components/video-carousel-dialog.tsx",
"chars": 4189,
"preview": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\n\nimport { SerperSearchResultItem } from '@/lib/types'\n"
},
{
"path": "components/video-result-grid.tsx",
"chars": 4151,
"preview": "'use client'\n\nimport Image from 'next/image'\n\nimport { PlusCircle } from 'lucide-react'\n\nimport { SerperSearchResultItem"
},
{
"path": "components/video-search-results.tsx",
"chars": 1300,
"preview": "/* eslint-disable @next/next/no-img-element */\n'use client'\n\nimport { SerperSearchResultItem, SerperSearchResults } from"
},
{
"path": "components.json",
"chars": 444,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "config/models/cloud.json",
"chars": 1672,
"preview": "{\n \"version\": 1,\n \"models\": {\n \"byMode\": {\n \"quick\": {\n \"speed\": {\n \"id\": \"gemini-3.1-flash-li"
},
{
"path": "config/models/default.json",
"chars": 1528,
"preview": "{\n \"version\": 1,\n \"models\": {\n \"byMode\": {\n \"quick\": {\n \"speed\": {\n \"id\": \"gpt-5-mini\",\n "
},
{
"path": "docker-compose.yaml",
"chars": 2098,
"preview": "# Docker Compose configuration for the morphic-stack development environment\n\nname: morphic-stack\nservices:\n morphic:\n "
},
{
"path": "docs/CONFIGURATION.md",
"chars": 10562,
"preview": "# Configuration Guide\n\nThis guide covers the optional features and their configuration in Morphic.\n\n## Table of Contents"
},
{
"path": "docs/DOCKER.md",
"chars": 2830,
"preview": "# Docker Guide\n\nThis guide covers running Morphic with Docker, including development setup, prebuilt images, and deploym"
},
{
"path": "drizzle/0000_black_lifeguard.sql",
"chars": 3146,
"preview": "CREATE TABLE \"chats\" (\n\t\"id\" varchar(191) PRIMARY KEY NOT NULL,\n\t\"created_at\" timestamp DEFAULT now() NOT NULL,\n\t\"title\""
},
{
"path": "drizzle/0001_thin_supreme_intelligence.sql",
"chars": 132,
"preview": "ALTER TABLE \"messages\" ADD COLUMN \"updated_at\" timestamp;--> statement-breakpoint\nALTER TABLE \"messages\" ADD COLUMN \"met"
},
{
"path": "drizzle/0002_material_crystal.sql",
"chars": 67,
"preview": "ALTER TABLE \"messages\" ALTER COLUMN \"metadata\" SET DATA TYPE jsonb;"
},
{
"path": "drizzle/0003_heavy_whirlwind.sql",
"chars": 455,
"preview": "CREATE TABLE \"feedback\" (\n\t\"id\" varchar(191) PRIMARY KEY NOT NULL,\n\t\"user_id\" varchar(255),\n\t\"sentiment\" varchar(256) NO"
},
{
"path": "drizzle/0004_natural_wallow.sql",
"chars": 316,
"preview": "CREATE INDEX \"chats_user_id_idx\" ON \"chats\" USING btree (\"user_id\");--> statement-breakpoint\nCREATE INDEX \"chats_user_id"
},
{
"path": "drizzle/0005_awesome_riptide.sql",
"chars": 400,
"preview": "ALTER TABLE \"chats\" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint\nALTER TABLE \"feedback\" ENABLE ROW LEVEL SECURITY;"
},
{
"path": "drizzle/0006_brainy_wrecking_crew.sql",
"chars": 1250,
"preview": "CREATE POLICY \"users_manage_own_chats\" ON \"chats\" AS PERMISSIVE FOR ALL TO public USING (user_id = current_setting('app."
},
{
"path": "drizzle/0007_illegal_mephistopheles.sql",
"chars": 683,
"preview": "CREATE POLICY \"public_chats_readable\" ON \"chats\" AS PERMISSIVE FOR SELECT TO public USING (visibility = 'public');--> st"
},
{
"path": "drizzle/0008_glamorous_riptide.sql",
"chars": 49,
"preview": "ALTER TABLE \"feedback\" ENABLE ROW LEVEL SECURITY;"
},
{
"path": "drizzle/0009_thankful_may_parker.sql",
"chars": 76,
"preview": "CREATE INDEX \"chats_id_user_id_idx\" ON \"chats\" USING btree (\"id\",\"user_id\");"
},
{
"path": "drizzle/0010_lonely_kang.sql",
"chars": 294,
"preview": "DO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_policies\n WHERE schemaname = 'public' AND tablename = 'feedback' A"
},
{
"path": "drizzle/meta/0000_snapshot.json",
"chars": 12821,
"preview": "{\n \"id\": \"bd2133a6-1281-44a1-8619-c1f0407dfb77\",\n \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0001_snapshot.json",
"chars": 13124,
"preview": "{\n \"id\": \"90075b6a-dc27-4224-beb5-1ce34abc7fc5\",\n \"prevId\": \"bd2133a6-1281-44a1-8619-c1f0407dfb77\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0002_snapshot.json",
"chars": 13125,
"preview": "{\n \"id\": \"8e9de712-037f-4132-8f06-b3d2f57dd68a\",\n \"prevId\": \"90075b6a-dc27-4224-beb5-1ce34abc7fc5\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0003_snapshot.json",
"chars": 15304,
"preview": "{\n \"id\": \"c9b8dd61-7cb9-4f3b-9e5f-811592e56563\",\n \"prevId\": \"8e9de712-037f-4132-8f06-b3d2f57dd68a\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0004_snapshot.json",
"chars": 16678,
"preview": "{\n \"id\": \"28e1690d-994a-4027-abe2-5f283bb4c720\",\n \"prevId\": \"c9b8dd61-7cb9-4f3b-9e5f-811592e56563\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0005_snapshot.json",
"chars": 16895,
"preview": "{\n \"id\": \"c2240142-0d69-482e-b6e0-a9561c421aa2\",\n \"prevId\": \"28e1690d-994a-4027-abe2-5f283bb4c720\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0006_snapshot.json",
"chars": 18523,
"preview": "{\n \"id\": \"60e18ccb-ad03-4315-bee3-286b827d26fe\",\n \"prevId\": \"c2240142-0d69-482e-b6e0-a9561c421aa2\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0007_snapshot.json",
"chars": 19502,
"preview": "{\n \"id\": \"a1b15544-fb28-4c05-82c2-8e81ea3d5689\",\n \"prevId\": \"60e18ccb-ad03-4315-bee3-286b827d26fe\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0008_snapshot.json",
"chars": 19501,
"preview": "{\n \"id\": \"c631d669-f529-4fda-8f77-1db7cc5310e4\",\n \"prevId\": \"a1b15544-fb28-4c05-82c2-8e81ea3d5689\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0009_snapshot.json",
"chars": 20054,
"preview": "{\n \"id\": \"09bf63d0-855c-4ef2-85b7-9f63220f2566\",\n \"prevId\": \"c631d669-f529-4fda-8f77-1db7cc5310e4\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/0010_snapshot.json",
"chars": 20256,
"preview": "{\n \"id\": \"413889c5-099c-4a0c-8a67-b5b1d0605366\",\n \"prevId\": \"09bf63d0-855c-4ef2-85b7-9f63220f2566\",\n \"version\": \"7\",\n"
},
{
"path": "drizzle/meta/_journal.json",
"chars": 1665,
"preview": "{\n \"version\": \"7\",\n \"dialect\": \"postgresql\",\n \"entries\": [\n {\n \"idx\": 0,\n \"version\": \"7\",\n \"when\": "
},
{
"path": "drizzle/relations.ts",
"chars": 544,
"preview": "import { relations } from 'drizzle-orm/relations'\n\nimport { chats, messages, parts } from './schema'\n\nexport const messa"
},
{
"path": "drizzle/schema.ts",
"chars": 7733,
"preview": "import { sql } from 'drizzle-orm'\nimport {\n check,\n foreignKey,\n index,\n integer,\n json,\n jsonb,\n pgPolicy,\n pgT"
},
{
"path": "drizzle.config.ts",
"chars": 396,
"preview": "import * as dotenv from 'dotenv'\nimport { defineConfig } from 'drizzle-kit'\n\nimport 'dotenv/config'\n\n// Load from .env.l"
},
{
"path": "hooks/use-auth-check.tsx",
"chars": 1139,
"preview": "'use client'\n\nimport { useEffect, useState } from 'react'\n\nimport { User } from '@supabase/supabase-js'\n\nimport { create"
},
{
"path": "hooks/use-current-user-image.ts",
"chars": 613,
"preview": "import { useEffect, useState } from 'react'\n\nimport { createClient } from '@/lib/supabase/client'\n\nexport const useCurre"
},
{
"path": "hooks/use-current-user-name.ts",
"chars": 632,
"preview": "import { useEffect, useState } from 'react'\n\nimport { createClient } from '@/lib/supabase/client'\n\nexport const useCurre"
},
{
"path": "hooks/use-file-dropzone.ts",
"chars": 3078,
"preview": "import { useCallback, useState } from 'react'\n\nimport { toast } from 'sonner'\n\nimport { UploadedFile } from '@/lib/types"
},
{
"path": "hooks/use-mobile.tsx",
"chars": 565,
"preview": "import * as React from 'react'\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n const [isMobile, setIsM"
},
{
"path": "instrumentation.ts",
"chars": 582,
"preview": "import { registerOTel } from '@vercel/otel'\nimport { LangfuseExporter } from 'langfuse-vercel'\n\nexport async function re"
},
{
"path": "lib/actions/__tests__/chat.test.ts",
"chars": 17535,
"preview": "import { revalidateTag } from 'next/cache'\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\n\nimport { gene"
},
{
"path": "lib/actions/__tests__/feedback.test.ts",
"chars": 6674,
"preview": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\n// Mock the modules before any imports\nvi.mock('@/lib/db'"
},
{
"path": "lib/actions/chat.ts",
"chars": 8415,
"preview": "'use server'\n\nimport { revalidateTag, unstable_cache } from 'next/cache'\n\nimport { generateChatTitle } from '@/lib/agent"
},
{
"path": "lib/actions/feedback.ts",
"chars": 2788,
"preview": "'use server'\n\nimport { eq } from 'drizzle-orm'\nimport { Langfuse } from 'langfuse'\n\nimport { db } from '@/lib/db'\nimport"
},
{
"path": "lib/actions/site-feedback.ts",
"chars": 3745,
"preview": "'use server'\n\nimport { db } from '@/lib/db'\nimport { feedback, generateId } from '@/lib/db/schema'\nimport { withOptional"
},
{
"path": "lib/agents/generate-related-questions.ts",
"chars": 1699,
"preview": "import { type ModelMessage, Output, streamText } from 'ai'\n\nimport { getRelatedQuestionsModel } from '../config/model-ty"
},
{
"path": "lib/agents/prompts/related-questions-prompt.ts",
"chars": 1103,
"preview": "export const RELATED_QUESTIONS_PROMPT = `You are a professional web researcher tasked with generating follow-up question"
},
{
"path": "lib/agents/prompts/search-mode-prompts.ts",
"chars": 18162,
"preview": "import {\n getContentTypesGuidance,\n isGeneralSearchProviderAvailable\n} from '@/lib/utils/search-config'\n\n// Search mod"
},
{
"path": "lib/agents/researcher.ts",
"chars": 4953,
"preview": "import { stepCountIs, tool, ToolLoopAgent } from 'ai'\n\nimport type { ResearcherTools } from '@/lib/types/agent'\nimport {"
},
{
"path": "lib/agents/title-generator.ts",
"chars": 2597,
"preview": "import { generateText } from 'ai'\n\nimport { getModel } from '../utils/registry'\nimport { isTracingEnabled } from '../uti"
},
{
"path": "lib/analytics/index.ts",
"chars": 389,
"preview": "/**\n * Analytics module\n *\n * Provides a unified interface for tracking analytics events.\n * Currently uses Vercel Analy"
},
{
"path": "lib/analytics/track-chat-event.ts",
"chars": 1670,
"preview": "import { track } from '@vercel/analytics/server'\n\nimport type { ChatEventData } from './types'\n\n/**\n * Track a chat even"
},
{
"path": "lib/analytics/types.ts",
"chars": 1272,
"preview": "/**\n * Analytics module types\n *\n * This module provides type definitions for analytics tracking.\n * The interfaces are "
},
{
"path": "lib/analytics/utils.ts",
"chars": 788,
"preview": "import type { UIMessage } from 'ai'\n\n/**\n * Calculate the conversation turn number from message history\n *\n * The turn n"
},
{
"path": "lib/auth/get-current-user.ts",
"chars": 1449,
"preview": "import { createClient } from '@/lib/supabase/server'\nimport { perfLog } from '@/lib/utils/perf-logging'\nimport { increme"
}
]
// ... and 95 more files (download for full content)
About this extraction
This page contains the full source code of the miurla/morphic GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 295 files (1012.7 KB), approximately 257.9k tokens, and a symbol index with 611 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.