```
├── .dockerignore (200 tokens)
├── .env.example (omitted)
├── .github/
├── dependabot.yml
├── workflows/
├── ci-cd.yml (1000 tokens)
├── codacy.yml (500 tokens)
├── codeql.yml (900 tokens)
├── .gitignore (100 tokens)
├── .prettierrc.json
├── CODE_OF_CONDUCT.md (500 tokens)
├── Dockerfile (400 tokens)
├── INSTALL.md (4.1k tokens)
├── LICENSE (omitted)
├── README.md (400 tokens)
├── app/
├── api/
├── chat/
├── api.ts (600 tokens)
├── db.ts (600 tokens)
├── route.ts (800 tokens)
├── utils.ts (2.1k tokens)
├── create-chat/
├── api.ts (200 tokens)
├── route.ts (200 tokens)
├── create-guest/
├── route.ts (400 tokens)
├── csrf/
├── route.ts (100 tokens)
├── health/
├── route.ts
├── models/
├── route.ts (500 tokens)
├── projects/
├── [projectId]/
├── route.ts (800 tokens)
├── route.ts (400 tokens)
├── providers/
├── route.ts (300 tokens)
├── rate-limits/
├── api.ts (200 tokens)
├── route.ts (200 tokens)
├── toggle-chat-pin/
├── route.ts (200 tokens)
├── update-chat-model/
├── route.ts (300 tokens)
├── user-key-status/
├── route.ts (300 tokens)
├── user-keys/
├── route.ts (900 tokens)
├── user-preferences/
├── favorite-models/
├── route.ts (600 tokens)
├── route.ts (900 tokens)
├── auth/
├── callback/
├── route.ts (400 tokens)
├── error/
├── page.tsx (400 tokens)
├── login-page.tsx (600 tokens)
├── login/
├── actions.ts (100 tokens)
├── page.tsx (100 tokens)
├── c/
├── [chatId]/
├── page.tsx (200 tokens)
├── components/
├── chat-input/
├── button-file-upload.tsx (800 tokens)
├── button-search.tsx (300 tokens)
├── chat-input.tsx (1300 tokens)
├── file-items.tsx (600 tokens)
├── file-list.tsx (300 tokens)
├── popover-content-auth.tsx (500 tokens)
├── suggestions.tsx (900 tokens)
├── chat/
├── chat-container.tsx (100 tokens)
├── chat.tsx (1400 tokens)
├── conversation.tsx (700 tokens)
├── dialog-auth.tsx (500 tokens)
├── feedback-widget.tsx (500 tokens)
├── get-sources.ts (200 tokens)
├── link-markdown.tsx (200 tokens)
├── message-assistant.tsx (1300 tokens)
├── message-user.tsx (1700 tokens)
├── message.tsx (400 tokens)
├── quote-button.tsx (300 tokens)
├── reasoning.tsx (300 tokens)
├── search-images.tsx (400 tokens)
├── sources-list.tsx (1000 tokens)
├── syncRecentMessages.ts (300 tokens)
├── tool-invocation.tsx (3k tokens)
├── use-chat-core.ts (3k tokens)
├── use-chat-operations.ts (700 tokens)
├── use-file-upload.ts (400 tokens)
├── use-model.ts (600 tokens)
├── useAssistantMessageSelection.ts (500 tokens)
├── utils.ts (300 tokens)
├── header-go-back.tsx (100 tokens)
├── history/
├── chat-preview-panel.tsx (1600 tokens)
├── command-footer.tsx (600 tokens)
├── command-history.tsx (4k tokens)
├── drawer-history.tsx (2.4k tokens)
├── history-trigger.tsx (500 tokens)
├── utils.ts (700 tokens)
├── layout/
├── app-info/
├── app-info-content.tsx (100 tokens)
├── app-info-trigger.tsx (500 tokens)
├── button-new-chat.tsx (200 tokens)
├── dialog-publish.tsx (1100 tokens)
├── feedback/
├── feedback-trigger.tsx (400 tokens)
├── header-sidebar-trigger.tsx (200 tokens)
├── header.tsx (600 tokens)
├── layout-app.tsx (100 tokens)
├── settings/
├── apikeys/
├── byok-section.tsx (2.3k tokens)
├── appearance/
├── index.tsx
├── interaction-preferences.tsx (500 tokens)
├── layout-settings.tsx (2.1k tokens)
├── theme-selection.tsx (300 tokens)
├── connections/
├── connections-placeholder.tsx (100 tokens)
├── developer-tools.tsx (900 tokens)
├── ollama-section.tsx (800 tokens)
├── general/
├── account-management.tsx (300 tokens)
├── system-prompt.tsx (500 tokens)
├── user-profile.tsx (200 tokens)
├── models/
├── model-visibility-settings.tsx (1200 tokens)
├── models-settings.tsx (2.3k tokens)
├── use-favorite-models.ts (1000 tokens)
├── settings-content.tsx (1700 tokens)
├── settings-trigger.tsx (300 tokens)
├── sidebar/
├── app-sidebar.tsx (1200 tokens)
├── dialog-create-project.tsx (600 tokens)
├── dialog-delete-chat.tsx (200 tokens)
├── dialog-delete-project.tsx (500 tokens)
├── project-chat-item.tsx (1200 tokens)
├── sidebar-item-menu.tsx (700 tokens)
├── sidebar-item.tsx (1200 tokens)
├── sidebar-list.tsx (200 tokens)
├── sidebar-project-item.tsx (1700 tokens)
├── sidebar-project-menu.tsx (500 tokens)
├── sidebar-project.tsx (300 tokens)
├── user-menu.tsx (600 tokens)
├── multi-chat/
├── multi-chat-input.tsx (700 tokens)
├── multi-chat.tsx (2.5k tokens)
├── multi-conversation.tsx (1400 tokens)
├── use-multi-chat.ts (400 tokens)
├── suggestions/
├── prompt-system.tsx (200 tokens)
├── favicon.ico
├── globals.css (1100 tokens)
├── hooks/
├── use-breakpoint.ts (100 tokens)
├── use-chat-draft.ts (200 tokens)
├── use-click-outside.tsx (200 tokens)
├── use-key-shortcut.tsx (100 tokens)
├── use-mobile.ts (100 tokens)
├── layout-client.tsx (100 tokens)
├── layout.tsx (600 tokens)
├── not-found.tsx (100 tokens)
├── opengraph-image.alt
├── opengraph-image.jpg
├── p/
├── [projectId]/
├── page.tsx (200 tokens)
├── project-view.tsx (2.6k tokens)
├── page.tsx (100 tokens)
├── share/
├── [chatId]/
├── article.tsx (700 tokens)
├── header.tsx (100 tokens)
├── page.tsx (400 tokens)
├── types/
├── api.types.ts (300 tokens)
├── database.types.ts (2.8k tokens)
├── components.json (100 tokens)
├── components/
├── common/
├── button-copy.tsx (100 tokens)
├── feedback-form.tsx (1200 tokens)
├── model-selector/
├── base.tsx (2.6k tokens)
├── pro-dialog.tsx (800 tokens)
├── sub-menu.tsx (1000 tokens)
├── multi-model-selector/
├── base.tsx (3.8k tokens)
├── icons/
├── anthropic.tsx (100 tokens)
├── claude.tsx (400 tokens)
├── deepseek.tsx (500 tokens)
├── gemini.tsx (200 tokens)
├── google.tsx (300 tokens)
├── grok.tsx (200 tokens)
├── meta.tsx (1600 tokens)
├── mistral.tsx (200 tokens)
├── ollama.tsx (100 tokens)
├── openai.tsx (400 tokens)
├── openrouter.tsx (200 tokens)
├── perplexity.tsx (300 tokens)
├── x.tsx (100 tokens)
├── xai.tsx (100 tokens)
├── zola.tsx (400 tokens)
├── motion-primitives/
├── morphing-dialog.tsx (1900 tokens)
├── morphing-popover.tsx (1100 tokens)
├── progressive-blur.tsx (300 tokens)
├── text-morph.tsx (400 tokens)
├── useClickOutside.tsx (200 tokens)
├── prompt-kit/
├── chat-container.tsx (300 tokens)
├── code-block.tsx (400 tokens)
├── file-upload.tsx (900 tokens)
├── loader.tsx (200 tokens)
├── markdown.tsx (700 tokens)
├── message.tsx (500 tokens)
├── prompt-input.tsx (900 tokens)
├── prompt-suggestion.tsx (600 tokens)
├── scroll-button.tsx (200 tokens)
├── ui/
├── alert-dialog.tsx (800 tokens)
├── avatar.tsx (200 tokens)
├── badge.tsx (300 tokens)
├── button.tsx (400 tokens)
├── card.tsx (400 tokens)
├── checkbox.tsx (200 tokens)
├── command.tsx (1000 tokens)
├── dialog.tsx (800 tokens)
├── drawer.tsx (800 tokens)
├── dropdown-menu.tsx (1600 tokens)
├── hover-card.tsx (300 tokens)
├── input.tsx (200 tokens)
├── label.tsx (100 tokens)
├── popover.tsx (300 tokens)
├── progress.tsx (100 tokens)
├── scroll-area.tsx (300 tokens)
├── select.tsx (1200 tokens)
├── separator.tsx (100 tokens)
├── sheet.tsx (800 tokens)
├── sidebar.tsx (4.3k tokens)
├── skeleton.tsx (100 tokens)
├── sonner.tsx (100 tokens)
├── switch.tsx (200 tokens)
├── tabs.tsx (400 tokens)
├── textarea.tsx (100 tokens)
├── toast.tsx (400 tokens)
├── tooltip.tsx (400 tokens)
├── docker-compose.ollama.yml (200 tokens)
├── docker-compose.yml (100 tokens)
├── eslint.config.mjs (100 tokens)
├── lib/
├── api.ts (1100 tokens)
├── chat-store/
├── chats/
├── api.ts (1400 tokens)
├── provider.tsx (1300 tokens)
├── messages/
├── api.ts (1000 tokens)
├── provider.tsx (700 tokens)
├── persist.ts (1400 tokens)
├── session/
├── provider.tsx (100 tokens)
├── types.ts
├── config.ts (700 tokens)
├── csrf.ts (200 tokens)
├── encryption.ts (300 tokens)
├── fetch.ts (100 tokens)
├── file-handling.ts (800 tokens)
├── hooks/
├── use-chat-preview.tsx (1000 tokens)
├── mcp/
├── load-mcp-from-local.ts (100 tokens)
├── load-mcp-from-url.ts (100 tokens)
├── model-store/
├── provider.tsx (900 tokens)
├── utils.ts (300 tokens)
├── models/
├── data/
├── claude.ts (1500 tokens)
├── deepseek.ts (400 tokens)
├── gemini.ts (1400 tokens)
├── grok.ts (900 tokens)
├── llama.ts (800 tokens)
├── mistral.ts (1300 tokens)
├── ollama.ts (2k tokens)
├── openai.ts (2000 tokens)
├── openrouter.ts (4.9k tokens)
├── perplexity.ts (1000 tokens)
├── index.ts (800 tokens)
├── types.ts (300 tokens)
├── motion.ts
├── openproviders/
├── env.ts (200 tokens)
├── index.ts (900 tokens)
├── provider-map.ts (1000 tokens)
├── types.ts (700 tokens)
├── providers/
├── index.ts (300 tokens)
├── routes.ts (100 tokens)
├── sanitize.ts
├── server/
├── api.ts (300 tokens)
├── supabase/
├── client.ts (100 tokens)
├── config.ts
├── server-guest.ts (100 tokens)
├── server.ts (200 tokens)
├── tanstack-query/
├── tanstack-query-provider.tsx (100 tokens)
├── usage.ts (1300 tokens)
├── user-keys.ts (300 tokens)
├── user-preference-store/
├── provider.tsx (1400 tokens)
├── utils.ts (400 tokens)
├── user-store/
├── api.ts (400 tokens)
├── provider.tsx (500 tokens)
├── user/
├── api.ts (300 tokens)
├── types.ts (100 tokens)
├── utils.ts (200 tokens)
├── middleware.ts (400 tokens)
├── next.config.ts (100 tokens)
├── package-lock.json (omitted)
├── package.json (500 tokens)
├── postcss.config.mjs
├── public/
├── banner_cloud.jpg
├── banner_forest.jpg
├── banner_ocean.jpg
├── button/
├── github.svg (2.7k tokens)
├── cover_zola.jpg
├── tsconfig.json (100 tokens)
├── utils/
├── supabase/
├── middleware.ts (500 tokens)
```
## /.dockerignore
```dockerignore path="/.dockerignore"
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js
.next
out
# Production
build
dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
.dockerignore
# CI/CD
.github
# Documentation
README.md
*.md
# Testing
coverage
.nyc_output
jest.config.js
# Linting
.eslintrc*
.prettierrc*
# TypeScript
*.tsbuildinfo
```
## /.github/dependabot.yml
```yml path="/.github/dependabot.yml"
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly" # Less frequent
open-pull-requests-limit: 2 # Fewer PRs
commit-message:
prefix: "deps" # Clean commit history
```
## /.github/workflows/ci-cd.yml
```yml path="/.github/workflows/ci-cd.yml"
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
NEXT_PUBLIC_VERCEL_URL: ${{ vars.NEXT_PUBLIC_VERCEL_URL }}
jobs:
validate:
name: Lint and Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript checks
run: npm run type-check
# TODO: Uncomment when tests are added
# - name: Run Tests
# run: npm test
build:
name: Build Application
needs: validate
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.build-cache.outputs.key }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate build cache key
id: build-cache
run: echo "key=build-${{ hashFiles('**/*.ts', '**/*.tsx', '**/*.js', 'package-lock.json') }}" >> $GITHUB_OUTPUT
- name: Check build cache
id: check-build-cache
uses: actions/cache@v4
with:
path: .next
key: ${{ steps.build-cache.outputs.key }}
- name: Build application
if: steps.check-build-cache.outputs.cache-hit != 'true'
run: npm run build
- name: Cache build output
if: steps.check-build-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: .next
key: ${{ steps.build-cache.outputs.key }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: |
.next/standalone
.next/static
public
package.json
next.config.ts
docker:
name: Build and Push Docker Image
needs: [validate, build]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: .
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_VERCEL_URL=${{ env.NEXT_PUBLIC_VERCEL_URL }}
# deploy:
# name: Deploy Application
# needs: docker
# if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
# runs-on: ubuntu-latest
# environment:
# name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
# steps:
# - name: Deploy to Environment
# run: |
# echo "Deploying to ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}"
# # Add your deployment commands here
# # Example for a docker-compose deployment:
# # ssh user@server "cd /path/to/app && docker-compose pull && docker-compose up -d"
# env:
# DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
# NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
# NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
# SUPABASE_SERVICE_ROLE: ${{ secrets.SUPABASE_SERVICE_ROLE }}
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
# CSRF_SECRET: ${{ secrets.CSRF_SECRET }}
```
## /.github/workflows/codacy.yml
```yml path="/.github/workflows/codacy.yml"
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow checks out code, performs a Codacy security scan
# and integrates the results with the
# GitHub Advanced Security code scanning feature. For more information on
# the Codacy security scan action usage and parameters, see
# https://github.com/codacy/codacy-analysis-cli-action.
# For more information on Codacy Analysis CLI in general, see
# https://github.com/codacy/codacy-analysis-cli.
name: Codacy Security Scan
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '39 10 * * 6'
permissions:
contents: read
jobs:
codacy-security-scan:
permissions:
contents: read # for actions/checkout to fetch code
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
name: Codacy Security Scan
runs-on: ubuntu-latest
steps:
# Checkout the repository to the GitHub Actions runner
- name: Checkout code
uses: actions/checkout@v4
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
with:
# Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository
# You can also omit the token and run the tools that support default configurations
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
verbose: true
output: results.sarif
format: sarif
# Adjust severity of non-security issues
gh-code-scanning-compat: true
# Force 0 exit code to allow SARIF file generation
# This will handover control about PR rejection to the GitHub side
max-allowed-issues: 2147483647
# Upload the SARIF file generated in the previous step
- name: Upload SARIF results file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
## /.github/workflows/codeql.yml
```yml path="/.github/workflows/codeql.yml"
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '28 22 * * 6'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
```
## /.gitignore
```gitignore path="/.gitignore"
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# env
.env
# bun
bun.lockb
bun.lock
/.cursor
```
## /.prettierrc.json
```json path="/.prettierrc.json"
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": [
"@ianvs/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
]
}
```
## /CODE_OF_CONDUCT.md
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers of Zola pledge to make participation in our project and community a harassment-free experience for everyone—regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Accepting constructive feedback gracefully
- Focusing on what benefits the community
- Showing empathy toward other community members
Examples of unacceptable behavior include:
- The use of sexualized language or imagery and unwelcome sexual attention
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without permission
- Other conduct that would be inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair action in response to any behavior that violates this Code of Conduct.
They have the right to remove, edit, or reject comments, commits, issues, and other contributions not aligned with this Code, and may ban contributors whose behavior is harmful or inappropriate.
## Scope
This Code of Conduct applies within all project spaces and also in public spaces when someone is representing the project or its community—for example, via the official GitHub, social media, or other channels.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainer at [julien.thibeaut@gmail.com](mailto:cjulien.thibeaut@gmail.com). All reports will be reviewed and handled confidentially and appropriately.
Maintainers who fail to enforce this Code in good faith may face consequences as determined by other core contributors.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
## /Dockerfile
``` path="/Dockerfile"
# Base Node.js image
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies (including devDependencies for build)
RUN npm ci && npm cache clean --force
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Copy node_modules from deps
COPY --from=deps /app/node_modules ./node_modules
# Copy all project files
COPY . .
# Set Next.js telemetry to disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build
# Verify standalone build was created
RUN ls -la .next/ && \
if [ ! -d ".next/standalone" ]; then \
echo "ERROR: .next/standalone directory not found. Make sure output: 'standalone' is set in next.config.ts"; \
exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Set environment variables
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Copy standalone application
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Copy static assets
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose application port
EXPOSE 3000
# Set environment variable for port
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Health check to verify container is running properly
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start the application
CMD ["node", "server.js"]
```
## /INSTALL.md
# Zola Installation Guide
Zola is a free, open-source AI chat app with multi-model support. This guide covers how to install and run Zola on different platforms, including Docker deployment options.

## Prerequisites
- Node.js 18.x or later
- npm or yarn
- Git
- Supabase account (for auth and storage)
- API keys for supported AI models (OpenAI, Mistral, etc.) OR Ollama for local models
## Environment Setup
First, you'll need to set up your environment variables. Create a `.env.local` file in the root of the project with the variables from `.env.example`
```bash
# Supabase
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE=your_supabase_service_role_key
# OpenAI
OPENAI_API_KEY=your_openai_api_key
# Mistral
MISTRAL_API_KEY=your_mistral_api_key
# OpenRouter
OPENROUTER_API_KEY=your_openrouter_api_key
# CSRF Protection
CSRF_SECRET=your_csrf_secret_key
# Exa
EXA_API_KEY=your_exa_api_key
# Gemini
GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key
# Anthropic
ANTHROPIC_API_KEY=your_anthropic_api_key
# Xai
XAI_API_KEY=your_xai_api_key
# Ollama (for local AI models)
OLLAMA_BASE_URL=http://localhost:11434
# Optional: Set the URL for production
# NEXT_PUBLIC_VERCEL_URL=your_production_url
```
A `.env.example` file is included in the repository for reference. Copy this file to `.env.local` and update the values with your credentials.
### Generating a CSRF Secret
The `CSRF_SECRET` is used to protect your application against Cross-Site Request Forgery attacks. You need to generate a secure random string for this value. Here are a few ways to generate one:
#### Using Node.js
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
#### Using OpenSSL
```bash
openssl rand -hex 32
```
#### Using Python
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
Copy the generated value and add it to your `.env.local` file as the `CSRF_SECRET` value.
### BYOK (Bring Your Own Key) Setup
Zola supports BYOK functionality, allowing users to securely store and use their own API keys for AI providers. To enable this feature, you need to configure an encryption key for secure storage of user API keys.
#### Generating an Encryption Key
The `ENCRYPTION_KEY` is used to encrypt user API keys before storing them in the database. Generate a 32-byte base64-encoded key:
```bash
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Using OpenSSL
openssl rand -base64 32
# Using Python
python -c "import base64, secrets; print(base64.b64encode(secrets.token_bytes(32)).decode())"
```
Add the generated key to your `.env.local` file:
```bash
# Required for BYOK functionality
ENCRYPTION_KEY=your_generated_base64_encryption_key
```
**Important**:
- Keep this key secure and backed up - losing it will make existing user API keys unrecoverable
- Use the same key across all your deployment environments
- The key must be exactly 32 bytes when base64 decoded
With BYOK enabled, users can securely add their own API keys through the settings interface, giving them access to AI models using their personal accounts and usage limits.
#### Google OAuth Authentication
1. Go to your Supabase project dashboard
2. Navigate to Authentication > Providers
3. Find the "Google" provider
4. Enable it by toggling the switch
5. Configure the Google OAuth credentials:
- You'll need to set up OAuth 2.0 credentials in the Google Cloud Console
- Add your application's redirect URL: https://[YOUR_PROJECT_REF].supabase.co/auth/v1/callback
- Get the Client ID and Client Secret from Google Cloud Console
- Add these credentials to the Google provider settings in Supabase
Here are the detailed steps to set up Google OAuth:
1. Go to the Google Cloud Console
2. Create a new project or select an existing one
3. Enable the Google+ API
4. Go to Credentials > Create Credentials > OAuth Client ID
5. Configure the OAuth consent screen if you haven't already
6. Set the application type as "Web application"
7. Add these authorized redirect URIs:
- https://[YOUR_PROJECT_REF].supabase.co/auth/v1/callback
- http://localhost:3000/auth/callback (for local development)
8. Copy the Client ID and Client Secret
9. Go back to your Supabase dashboard
10. Paste the Client ID and Client Secret in the Google provider settings
11. Save the changes
#### Guest user setup
1. Go to your Supabase project dashboard
2. Navigate to Authentication > Providers
3. Toggle on "Allow anonymous sign-ins"
This allows users limited access to try the product before properly creating an account.
### Database Schema
Create the following tables in your Supabase SQL editor:
```sql
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY NOT NULL, -- Assuming the PK is from auth.users, typically not nullable
email TEXT NOT NULL,
anonymous BOOLEAN,
daily_message_count INTEGER,
daily_reset TIMESTAMPTZ,
display_name TEXT,
favorite_models TEXT[],
message_count INTEGER,
premium BOOLEAN,
profile_image TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_active_at TIMESTAMPTZ DEFAULT NOW(),
daily_pro_message_count INTEGER,
daily_pro_reset TIMESTAMPTZ,
system_prompt TEXT,
CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE -- Explicit FK definition
);
-- Projects table
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT projects_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Chats table
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
project_id UUID,
title TEXT,
model TEXT,
system_prompt TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
public BOOLEAN DEFAULT FALSE NOT NULL,
pinned BOOLEAN DEFAULT FALSE NOT NULL,
pinned_at TIMESTAMPTZ NULL,
CONSTRAINT chats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT chats_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Messages table
CREATE TABLE messages (
id SERIAL PRIMARY KEY, -- Using SERIAL for auto-incrementing integer ID
chat_id UUID NOT NULL,
user_id UUID,
content TEXT,
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'data')), -- Added CHECK constraint
experimental_attachments JSONB, -- Storing Attachment[] as JSONB
parts JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
CONSTRAINT messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
message_group_id TEXT,
model TEXT
);
-- Chat attachments table
CREATE TABLE chat_attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID NOT NULL,
user_id UUID NOT NULL,
file_url TEXT NOT NULL,
file_name TEXT,
file_type TEXT,
file_size INTEGER, -- Assuming INTEGER for file size
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_chat FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Feedback table
CREATE TABLE feedback (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT feedback_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- User keys table for BYOK (Bring Your Own Key) integration
CREATE TABLE user_keys (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
encrypted_key TEXT NOT NULL,
iv TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, provider)
);
-- User preferences table
CREATE TABLE user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
layout TEXT DEFAULT 'fullscreen',
prompt_suggestions BOOLEAN DEFAULT true,
show_tool_invocations BOOLEAN DEFAULT true,
show_conversation_previews BOOLEAN DEFAULT true,
multi_model_enabled BOOLEAN DEFAULT false,
hidden_models TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Optional: keep updated_at in sync for user_preferences
CREATE OR REPLACE FUNCTION update_user_preferences_updated_at()
RETURNS TRIGGER AS $
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER update_user_preferences_timestamp
BEFORE UPDATE ON user_preferences
FOR EACH ROW
EXECUTE PROCEDURE update_user_preferences_updated_at();
-- RLS (Row Level Security) Reminder
-- Ensure RLS is enabled on these tables in your Supabase dashboard
-- and appropriate policies are created.
-- Example policies (adapt as needed):
-- ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY "Users can view their own data." ON users FOR SELECT USING (auth.uid() = id);
-- CREATE POLICY "Users can update their own data." ON users FOR UPDATE USING (auth.uid() = id);
-- ... add policies for other tables (chats, messages, etc.) ...
```
### Storage Setup
Create the buckets `chat-attachments` and `avatars` in your Supabase dashboard:
1. Go to Storage in your Supabase dashboard
2. Click "New bucket" and create two buckets: `chat-attachments` and `avatars`
3. Configure public access permissions for both buckets
## Ollama Setup (Local AI Models)
Ollama allows you to run AI models locally on your machine. Zola has built-in support for Ollama with automatic model detection.
### Installing Ollama
#### macOS and Linux
```bash
curl -fsSL https://ollama.ai/install.sh | sh
```
#### Windows
Download and install from [ollama.ai](https://ollama.ai/download)
#### Docker
```bash
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
```
### Setting up Models
After installing Ollama, you can download and run models:
```bash
# Popular models to get started
ollama pull llama3.2 # Meta's Llama 3.2 (3B)
ollama pull llama3.2:1b # Smaller, faster version
ollama pull gemma2:2b # Google's Gemma 2 (2B)
ollama pull qwen2.5:3b # Alibaba's Qwen 2.5 (3B)
ollama pull phi3.5:3.8b # Microsoft's Phi 3.5 (3.8B)
# Coding-focused models
ollama pull codellama:7b # Meta's Code Llama
ollama pull deepseek-coder:6.7b # DeepSeek Coder
# List available models
ollama list
# Start the Ollama service (if not running)
ollama serve
```
### Zola + Ollama Integration
Zola automatically detects all models available in your Ollama installation. No additional configuration is needed!
**Features:**
- **Automatic Model Detection**: Zola scans your Ollama instance and makes all models available
- **Intelligent Categorization**: Models are automatically categorized by family (Llama, Gemma, Qwen, etc.)
- **Smart Tagging**: Models get appropriate tags (local, open-source, coding, size-based)
- **No Pro Restrictions**: All Ollama models are free to use
- **Custom Endpoints**: Support for remote Ollama instances
### Configuration Options
#### Default Configuration
By default, Zola connects to Ollama at `http://localhost:11434`. This works for local installations.
#### Custom Ollama URL
To use a remote Ollama instance or custom port:
```bash
# In your .env.local file
OLLAMA_BASE_URL=http://192.168.1.100:11434
```
#### Runtime Configuration
You can also set the Ollama URL at runtime:
```bash
OLLAMA_BASE_URL=http://your-ollama-server:11434 npm run dev
```
#### Settings UI
Zola includes a settings interface where you can:
- Enable/disable Ollama integration
- Configure custom Ollama base URLs
- Add multiple Ollama instances
- Manage other AI providers
Access settings through the gear icon in the interface.
### Docker with Ollama
For a complete Docker setup with both Zola and Ollama:
```bash
# Use the provided Docker Compose file
docker-compose -f docker-compose.ollama.yml up
# Or manually with separate containers
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
docker run -p 3000:3000 -e OLLAMA_BASE_URL=http://ollama:11434 zola
```
The `docker-compose.ollama.yml` file includes:
- Ollama service with GPU support (if available)
- Automatic model pulling
- Health checks
- Proper networking between services
### Troubleshooting Ollama
#### Ollama not detected
1. Ensure Ollama is running: `ollama serve`
2. Check the URL: `curl http://localhost:11434/api/tags`
3. Verify firewall settings if using remote Ollama
#### Models not appearing
1. Refresh the models list in Zola settings
2. Check Ollama has models: `ollama list`
3. Restart Zola if models were added after startup
#### Performance optimization
1. Use smaller models for faster responses (1B-3B parameters)
2. Enable GPU acceleration if available
3. Adjust Ollama's `OLLAMA_NUM_PARALLEL` environment variable
## Disabling Ollama
Ollama is automatically enabled in development and disabled in production. If you want to disable it in development, you can use an environment variable:
### Environment Variable
Add this to your `.env.local` file:
```bash
# Disable Ollama in development
DISABLE_OLLAMA=true
```
### Note
- In **production**, Ollama is disabled by default to avoid connection errors
- In **development**, Ollama is enabled by default for local AI model testing
- Use `DISABLE_OLLAMA=true` to disable it in development
### Recommended Models by Use Case
#### General Chat
- `llama3.2:3b` - Good balance of quality and speed
- `gemma2:2b` - Fast and efficient
- `qwen2.5:3b` - Excellent multilingual support
#### Coding
- `codellama:7b` - Specialized for code generation
- `deepseek-coder:6.7b` - Strong coding capabilities
- `phi3.5:3.8b` - Good for code explanation
#### Creative Writing
- `llama3.2:8b` - Better for creative tasks
- `mistral:7b` - Good instruction following
#### Fast Responses
- `llama3.2:1b` - Ultra-fast, basic capabilities
- `gemma2:2b` - Quick and capable
## Local Installation
### macOS / Linux
```bash
# Clone the repository
git clone https://github.com/ibelick/zola.git
cd zola
# Install dependencies
npm install
# Run the development server
npm run dev
```
### Windows
```bash
# Clone the repository
git clone https://github.com/ibelick/zola.git
cd zola
# Install dependencies
npm install
# Run the development server
npm run dev
```
The application will be available at [http://localhost:3000](http://localhost:3000).
## Supabase Setup
Zola requires Supabase for authentication and storage. Follow these steps to set up your Supabase project:
1. Create a new project at [Supabase](https://supabase.com)
2. Set up the database schema using the SQL script below
3. Create storage buckets for chat attachments
4. Configure authentication providers (Google OAuth)
5. Get your API keys and add them to your `.env.local` file
## Docker Installation
### Option 1: Single Container with Docker
Create a `Dockerfile` in the root of your project if that doesnt exist:
```dockerfile
# Base Node.js image
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies with clean slate
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Copy node_modules from deps
COPY --from=deps /app/node_modules ./node_modules
# Copy all project files
COPY . .
# Set Next.js telemetry to disabled
ENV NEXT_TELEMETRY_DISABLED 1
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Set environment variables
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Set the correct permission for prerender cache
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
# Copy necessary files for production
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose application port
EXPOSE 3000
# Set environment variable for port
ENV PORT 3000
ENV HOSTNAME 0.0.0.0
# Health check to verify container is running properly
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
# Start the application
CMD ["node", "server.js"]
```
Build and run the Docker container:
```bash
# Build the Docker image
docker build -t zola .
# Run the container
docker run -p 3000:3000 \
-e NEXT_PUBLIC_SUPABASE_URL=your_supabase_url \
-e NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key \
-e SUPABASE_SERVICE_ROLE=your_supabase_service_role_key \
-e OPENAI_API_KEY=your_openai_api_key \
-e MISTRAL_API_KEY=your_mistral_api_key \
zola
```
### Option 2: Docker Compose
Create a `docker-compose.yml` file in the root of your project:
```yaml
version: "3"
services:
zola:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- SUPABASE_SERVICE_ROLE=${SUPABASE_SERVICE_ROLE}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- MISTRAL_API_KEY=${MISTRAL_API_KEY}
restart: unless-stopped
```
Run with Docker Compose:
```bash
# Start the services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the services
docker-compose down
```
### Option 3: Docker Compose with Ollama (Recommended for Local AI)
For a complete setup with both Zola and Ollama running locally, use the provided `docker-compose.ollama.yml`:
```bash
# Start both Zola and Ollama services
docker-compose -f docker-compose.ollama.yml up -d
# View logs
docker-compose -f docker-compose.ollama.yml logs -f
# Stop the services
docker-compose -f docker-compose.ollama.yml down
```
This setup includes:
- **Ollama service** with GPU support (if available)
- **Automatic model pulling** (llama3.2:3b by default)
- **Health checks** for both services
- **Proper networking** between Zola and Ollama
- **Volume persistence** for Ollama models
The Ollama service will be available at `http://localhost:11434` and Zola will automatically detect all available models.
To customize which models are pulled, edit the `docker-compose.ollama.yml` file and modify the `OLLAMA_MODELS` environment variable:
```yaml
environment:
- OLLAMA_MODELS=llama3.2:3b,gemma2:2b,qwen2.5:3b
```
## Production Deployment
### Deploy to Vercel
The easiest way to deploy Zola is using Vercel:
1. Push your code to a Git repository (GitHub, GitLab, etc.)
2. Import the project into Vercel
3. Configure your environment variables
4. Deploy
```bash
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel
```
### Self-Hosted Production
For a self-hosted production environment, you'll need to build the application and run it:
```bash
# Build the application
npm run build
# Start the production server
npm start
```
## Configuration Options
You can customize various aspects of Zola by modifying the configuration files:
- `app/lib/config.ts`: Configure AI models, daily message limits, etc.
- `.env.local`: Set environment variables and API keys
## Troubleshooting
### Common Issues
1. **Connection to Supabase fails**
- Check your Supabase URL and API keys
- Ensure your IP address is allowed in Supabase
2. **AI models not responding**
- Verify your API keys for OpenAI/Mistral
- Check that the models specified in config are available
3. **Docker container exits immediately**
- Check logs using `docker logs <container_id>`
- Ensure all required environment variables are set
## Community and Support
- GitHub Issues: Report bugs or request features
- GitHub Discussions: Ask questions and share ideas
## License
Apache License 2.0
## /README.md
# Zola
[zola.chat](https://zola.chat)
**Zola** is the open-source chat interface for all your models.

## Features
- Multi-model support: OpenAI, Mistral, Claude, Gemini, Ollama (local models)
- Bring your own API key (BYOK) support via OpenRouter
- File uploads
- Clean, responsive UI with light/dark themes
- Built with Tailwind CSS, shadcn/ui, and prompt-kit
- Open-source and self-hostable
- Customizable: user system prompt, multiple layout options
- Local AI with Ollama: Run models locally with automatic model detection
- Full MCP support (wip)
## Quick Start
### Option 1: With OpenAI (Cloud)
```bash
git clone https://github.com/ibelick/zola.git
cd zola
npm install
echo "OPENAI_API_KEY=your-key" > .env.local
npm run dev
```
### Option 2: With Ollama (Local)
```bash
# Install and start Ollama
curl -fsSL https://ollama.ai/install.sh | sh
ollama pull llama3.2 # or any model you prefer
# Clone and run Zola
git clone https://github.com/ibelick/zola.git
cd zola
npm install
npm run dev
```
Zola will automatically detect your local Ollama models!
### Option 3: Docker with Ollama
```bash
git clone https://github.com/ibelick/zola.git
cd zola
docker-compose -f docker-compose.ollama.yml up
```
[](https://vercel.com/new/clone?repository-url=https://github.com/ibelick/zola)
To unlock features like auth, file uploads, see [INSTALL.md](./INSTALL.md).
## Built with
- [prompt-kit](https://prompt-kit.com/) — AI components
- [shadcn/ui](https://ui.shadcn.com) — core components
- [motion-primitives](https://motion-primitives.com) — animated components
- [vercel ai sdk](https://vercel.com/blog/introducing-the-vercel-ai-sdk) — model integration, AI features
- [supabase](https://supabase.com) — auth and storage
## Sponsors
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
## License
Apache License 2.0
## Notes
This is a beta release. The codebase is evolving and may change.
## /app/api/chat/api.ts
```ts path="/app/api/chat/api.ts"
import { saveFinalAssistantMessage } from "@/app/api/chat/db"
import type {
ChatApiParams,
LogUserMessageParams,
StoreAssistantMessageParams,
SupabaseClientType,
} from "@/app/types/api.types"
import { FREE_MODELS_IDS, NON_AUTH_ALLOWED_MODELS } from "@/lib/config"
import { getProviderForModel } from "@/lib/openproviders/provider-map"
import { sanitizeUserInput } from "@/lib/sanitize"
import { validateUserIdentity } from "@/lib/server/api"
import { checkUsageByModel, incrementUsage } from "@/lib/usage"
import { getUserKey, type ProviderWithoutOllama } from "@/lib/user-keys"
export async function validateAndTrackUsage({
userId,
model,
isAuthenticated,
}: ChatApiParams): Promise<SupabaseClientType | null> {
const supabase = await validateUserIdentity(userId, isAuthenticated)
if (!supabase) return null
// Check if user is authenticated
if (!isAuthenticated) {
// For unauthenticated users, only allow specific models
if (!NON_AUTH_ALLOWED_MODELS.includes(model)) {
throw new Error(
"This model requires authentication. Please sign in to access more models."
)
}
} else {
// For authenticated users, check API key requirements
const provider = getProviderForModel(model)
if (provider !== "ollama") {
const userApiKey = await getUserKey(
userId,
provider as ProviderWithoutOllama
)
// If no API key and model is not in free list, deny access
if (!userApiKey && !FREE_MODELS_IDS.includes(model)) {
throw new Error(
`This model requires an API key for ${provider}. Please add your API key in settings or use a free model.`
)
}
}
}
// Check usage limits for the model
await checkUsageByModel(supabase, userId, model, isAuthenticated)
return supabase
}
export async function incrementMessageCount({
supabase,
userId,
}: {
supabase: SupabaseClientType
userId: string
}): Promise<void> {
if (!supabase) return
try {
await incrementUsage(supabase, userId)
} catch (err) {
console.error("Failed to increment message count:", err)
// Don't throw error as this shouldn't block the chat
}
}
export async function logUserMessage({
supabase,
userId,
chatId,
content,
attachments,
model,
isAuthenticated,
message_group_id,
}: LogUserMessageParams): Promise<void> {
if (!supabase) return
const { error } = await supabase.from("messages").insert({
chat_id: chatId,
role: "user",
content: sanitizeUserInput(content),
experimental_attachments: attachments,
user_id: userId,
message_group_id,
})
if (error) {
console.error("Error saving user message:", error)
}
}
export async function storeAssistantMessage({
supabase,
chatId,
messages,
message_group_id,
model,
}: StoreAssistantMessageParams): Promise<void> {
if (!supabase) return
try {
await saveFinalAssistantMessage(
supabase,
chatId,
messages,
message_group_id,
model
)
} catch (err) {
console.error("Failed to save assistant messages:", err)
}
}
```
## /app/api/chat/db.ts
```ts path="/app/api/chat/db.ts"
import type { ContentPart, Message } from "@/app/types/api.types"
import type { Database, Json } from "@/app/types/database.types"
import type { SupabaseClient } from "@supabase/supabase-js"
const DEFAULT_STEP = 0
export async function saveFinalAssistantMessage(
supabase: SupabaseClient<Database>,
chatId: string,
messages: Message[],
message_group_id?: string,
model?: string
) {
const parts: ContentPart[] = []
const toolMap = new Map<string, ContentPart>()
const textParts: string[] = []
for (const msg of messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
textParts.push(part.text || "")
parts.push(part)
} else if (part.type === "tool-invocation" && part.toolInvocation) {
const { toolCallId, state } = part.toolInvocation
if (!toolCallId) continue
const existing = toolMap.get(toolCallId)
if (state === "result" || !existing) {
toolMap.set(toolCallId, {
...part,
toolInvocation: {
...part.toolInvocation,
args: part.toolInvocation?.args || {},
},
})
}
} else if (part.type === "reasoning") {
parts.push({
type: "reasoning",
reasoning: part.text || "",
details: [
{
type: "text",
text: part.text || "",
},
],
})
} else if (part.type === "step-start") {
parts.push(part)
}
}
} else if (msg.role === "tool" && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "tool-result") {
const toolCallId = part.toolCallId || ""
toolMap.set(toolCallId, {
type: "tool-invocation",
toolInvocation: {
state: "result",
step: DEFAULT_STEP,
toolCallId,
toolName: part.toolName || "",
result: part.result,
},
})
}
}
}
}
// Merge tool parts at the end
parts.push(...toolMap.values())
const finalPlainText = textParts.join("\n\n")
const { error } = await supabase.from("messages").insert({
chat_id: chatId,
role: "assistant",
content: finalPlainText || "",
parts: parts as unknown as Json,
message_group_id,
model,
})
if (error) {
console.error("Error saving final assistant message:", error)
throw new Error(`Failed to save assistant message: ${error.message}`)
} else {
console.log("Assistant message saved successfully (merged).")
}
}
```
## /app/api/chat/route.ts
```ts path="/app/api/chat/route.ts"
import { SYSTEM_PROMPT_DEFAULT } from "@/lib/config"
import { getAllModels } from "@/lib/models"
import { getProviderForModel } from "@/lib/openproviders/provider-map"
import type { ProviderWithoutOllama } from "@/lib/user-keys"
import { Attachment } from "@ai-sdk/ui-utils"
import { Message as MessageAISDK, streamText, ToolSet } from "ai"
import {
incrementMessageCount,
logUserMessage,
storeAssistantMessage,
validateAndTrackUsage,
} from "./api"
import { createErrorResponse, extractErrorMessage } from "./utils"
export const maxDuration = 60
type ChatRequest = {
messages: MessageAISDK[]
chatId: string
userId: string
model: string
isAuthenticated: boolean
systemPrompt: string
enableSearch: boolean
message_group_id?: string
editCutoffTimestamp?: string
}
export async function POST(req: Request) {
try {
const {
messages,
chatId,
userId,
model,
isAuthenticated,
systemPrompt,
enableSearch,
message_group_id,
editCutoffTimestamp,
} = (await req.json()) as ChatRequest
if (!messages || !chatId || !userId) {
return new Response(
JSON.stringify({ error: "Error, missing information" }),
{ status: 400 }
)
}
const supabase = await validateAndTrackUsage({
userId,
model,
isAuthenticated,
})
// Increment message count for successful validation
if (supabase) {
await incrementMessageCount({ supabase, userId })
}
const userMessage = messages[messages.length - 1]
// If editing, delete messages from cutoff BEFORE saving the new user message
if (supabase && editCutoffTimestamp) {
try {
await supabase
.from("messages")
.delete()
.eq("chat_id", chatId)
.gte("created_at", editCutoffTimestamp)
} catch (err) {
console.error("Failed to delete messages from cutoff:", err)
}
}
if (supabase && userMessage?.role === "user") {
await logUserMessage({
supabase,
userId,
chatId,
content: userMessage.content,
attachments: userMessage.experimental_attachments as Attachment[],
model,
isAuthenticated,
message_group_id,
})
}
const allModels = await getAllModels()
const modelConfig = allModels.find((m) => m.id === model)
if (!modelConfig || !modelConfig.apiSdk) {
throw new Error(`Model ${model} not found`)
}
const effectiveSystemPrompt = systemPrompt || SYSTEM_PROMPT_DEFAULT
let apiKey: string | undefined
if (isAuthenticated && userId) {
const { getEffectiveApiKey } = await import("@/lib/user-keys")
const provider = getProviderForModel(model)
apiKey =
(await getEffectiveApiKey(userId, provider as ProviderWithoutOllama)) ||
undefined
}
const result = streamText({
model: modelConfig.apiSdk(apiKey, { enableSearch }),
system: effectiveSystemPrompt,
messages: messages,
tools: {} as ToolSet,
maxSteps: 10,
onError: (err: unknown) => {
console.error("Streaming error occurred:", err)
// Don't set streamError anymore - let the AI SDK handle it through the stream
},
onFinish: async ({ response }) => {
if (supabase) {
await storeAssistantMessage({
supabase,
chatId,
messages:
response.messages as unknown as import("@/app/types/api.types").Message[],
message_group_id,
model,
})
}
},
})
return result.toDataStreamResponse({
sendReasoning: true,
sendSources: true,
getErrorMessage: (error: unknown) => {
console.error("Error forwarded to client:", error)
return extractErrorMessage(error)
},
})
} catch (err: unknown) {
console.error("Error in /api/chat:", err)
const error = err as {
code?: string
message?: string
statusCode?: number
}
return createErrorResponse(error)
}
}
```
## /app/api/chat/utils.ts
```ts path="/app/api/chat/utils.ts"
import { Message as MessageAISDK } from "ai"
/**
* Clean messages when switching between agents with different tool capabilities.
* This removes tool invocations and tool-related content from messages when tools are not available
* to prevent OpenAI API errors.
*/
export function cleanMessagesForTools(
messages: MessageAISDK[],
hasTools: boolean
): MessageAISDK[] {
if (hasTools) {
return messages
}
// If no tools available, clean all tool-related content
const cleanedMessages = messages
.map((message) => {
// Skip tool messages entirely when no tools are available
// Note: Using type assertion since AI SDK types might not include 'tool' role
if ((message as { role: string }).role === "tool") {
return null
}
if (message.role === "assistant") {
const cleanedMessage: MessageAISDK = { ...message }
if (message.toolInvocations && message.toolInvocations.length > 0) {
delete cleanedMessage.toolInvocations
}
if (Array.isArray(message.content)) {
const filteredContent = (
message.content as Array<{ type?: string; text?: string }>
).filter((part: { type?: string }) => {
if (part && typeof part === "object" && part.type) {
// Remove tool-call, tool-result, and tool-invocation parts
const isToolPart =
part.type === "tool-call" ||
part.type === "tool-result" ||
part.type === "tool-invocation"
return !isToolPart
}
return true
})
// Extract text content
const textParts = filteredContent.filter(
(part: { type?: string }) =>
part && typeof part === "object" && part.type === "text"
)
if (textParts.length > 0) {
// Combine text parts into a single string
const textContent = textParts
.map((part: { text?: string }) => part.text || "")
.join("\n")
.trim()
cleanedMessage.content = textContent || "[Assistant response]"
} else if (filteredContent.length === 0) {
// If no content remains after filtering, provide fallback
cleanedMessage.content = "[Assistant response]"
} else {
// Keep the filtered content as string if possible
cleanedMessage.content = "[Assistant response]"
}
}
// If the message has no meaningful content after cleaning, provide fallback
if (
!cleanedMessage.content ||
(typeof cleanedMessage.content === "string" &&
cleanedMessage.content.trim() === "")
) {
cleanedMessage.content = "[Assistant response]"
}
return cleanedMessage
}
// For user messages, clean any tool-related content from array content
if (message.role === "user" && Array.isArray(message.content)) {
const filteredContent = (
message.content as Array<{ type?: string }>
).filter((part: { type?: string }) => {
if (part && typeof part === "object" && part.type) {
const isToolPart =
part.type === "tool-call" ||
part.type === "tool-result" ||
part.type === "tool-invocation"
return !isToolPart
}
return true
})
if (
filteredContent.length !== (message.content as Array<unknown>).length
) {
return {
...message,
content:
filteredContent.length > 0 ? filteredContent : "User message",
}
}
}
return message
})
.filter((message): message is MessageAISDK => message !== null)
return cleanedMessages
}
/**
* Check if a message contains tool-related content
*/
export function messageHasToolContent(message: MessageAISDK): boolean {
return !!(
message.toolInvocations?.length ||
(message as { role: string }).role === "tool" ||
(Array.isArray(message.content) &&
(message.content as Array<{ type?: string }>).some(
(part: { type?: string }) =>
part &&
typeof part === "object" &&
part.type &&
(part.type === "tool-call" ||
part.type === "tool-result" ||
part.type === "tool-invocation")
))
)
}
/**
* Structured error type for API responses
*/
export type ApiError = Error & {
statusCode: number
code: string
}
/**
* Parse and handle stream errors from AI SDK
* @deprecated Use extractErrorMessage instead for streaming errors with toDataStreamResponse
* This is kept for legacy/fallback purposes or non-streaming error scenarios
* @param err - The error from streamText onError callback
* @returns Structured error with status code and error code
*/
export function handleStreamError(err: unknown): ApiError {
console.error("🛑 streamText error:", err)
// Extract error details from the AI SDK error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const aiError = (err as { error?: any })?.error
if (aiError) {
// Try to extract detailed error message from response body
let detailedMessage = ""
if (aiError.responseBody) {
try {
const parsed = JSON.parse(aiError.responseBody)
// Handle different error response formats
if (parsed.error?.message) {
detailedMessage = parsed.error.message
} else if (parsed.error && typeof parsed.error === "string") {
detailedMessage = parsed.error
} else if (parsed.message) {
detailedMessage = parsed.message
}
} catch {
// Fallback to generic message if parsing fails
}
}
// Handle specific API errors with proper status codes
if (aiError.statusCode === 402) {
// Payment required
const message =
detailedMessage || "Insufficient credits or payment required"
return Object.assign(new Error(message), {
statusCode: 402,
code: "PAYMENT_REQUIRED",
})
} else if (aiError.statusCode === 401) {
// Authentication error - use detailed message if available
const message =
detailedMessage ||
"Invalid API key or authentication failed. Please check your API key in settings."
return Object.assign(new Error(message), {
statusCode: 401,
code: "AUTHENTICATION_ERROR",
})
} else if (aiError.statusCode === 429) {
// Rate limit
const message =
detailedMessage || "Rate limit exceeded. Please try again later."
return Object.assign(new Error(message), {
statusCode: 429,
code: "RATE_LIMIT_EXCEEDED",
})
} else if (aiError.statusCode >= 400 && aiError.statusCode < 500) {
// Other client errors
const message = detailedMessage || aiError.message || "Request failed"
return Object.assign(new Error(message), {
statusCode: aiError.statusCode,
code: "CLIENT_ERROR",
})
} else {
// Server errors or other issues
const message = detailedMessage || aiError.message || "AI service error"
return Object.assign(new Error(message), {
statusCode: aiError.statusCode || 500,
code: "SERVER_ERROR",
})
}
} else {
// Fallback for unknown error format
return Object.assign(
new Error("AI generation failed. Please check your model or API key."),
{
statusCode: 500,
code: "UNKNOWN_ERROR",
}
)
}
}
/**
* Extract a user-friendly error message from various error types
* Used for streaming errors that need to be forwarded to the client
* @param error - The error from AI SDK or other sources
* @returns User-friendly error message string
*/
export function extractErrorMessage(error: unknown): string {
// Handle null/undefined
if (error == null) {
return "An unknown error occurred."
}
// Handle string errors
if (typeof error === "string") {
return error
}
// Handle Error objects
if (error instanceof Error) {
// Check for specific error patterns
if (
error.message.includes("invalid x-api-key") ||
error.message.includes("authentication_error")
) {
return "Invalid API key or authentication failed. Please check your API key in settings."
} else if (
error.message.includes("402") ||
error.message.includes("payment") ||
error.message.includes("credits")
) {
return "Insufficient credits or payment required."
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
return "Rate limit exceeded. Please try again later."
}
return error.message
}
// Handle AI SDK error objects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const aiError = (error as any)?.error
if (aiError) {
if (aiError.statusCode === 401) {
return "Invalid API key or authentication failed. Please check your API key in settings."
} else if (aiError.statusCode === 402) {
return "Insufficient credits or payment required."
} else if (aiError.statusCode === 429) {
return "Rate limit exceeded. Please try again later."
} else if (aiError.responseBody) {
try {
const parsed = JSON.parse(aiError.responseBody)
if (parsed.error?.message) {
return parsed.error.message
}
} catch {
// Fall through to generic message
}
}
return aiError.message || "Request failed"
}
return "An error occurred. Please try again."
}
/**
* Create error response for API endpoints
* @param error - Error object with optional statusCode and code
* @returns Response object with proper status and JSON body
*/
export function createErrorResponse(error: {
code?: string
message?: string
statusCode?: number
}): Response {
// Handle daily limit first (existing logic)
if (error.code === "DAILY_LIMIT_REACHED") {
return new Response(
JSON.stringify({ error: error.message, code: error.code }),
{ status: 403 }
)
}
// Handle stream errors with proper status codes
if (error.statusCode) {
return new Response(
JSON.stringify({
error: error.message || "Request failed",
code: error.code || "REQUEST_ERROR",
}),
{ status: error.statusCode }
)
}
// Fallback for other errors
return new Response(
JSON.stringify({ error: error.message || "Internal server error" }),
{ status: 500 }
)
}
```
## /app/api/create-chat/api.ts
```ts path="/app/api/create-chat/api.ts"
import { validateUserIdentity } from "@/lib/server/api"
import { checkUsageByModel } from "@/lib/usage"
type CreateChatInput = {
userId: string
title?: string
model: string
isAuthenticated: boolean
projectId?: string
}
export async function createChatInDb({
userId,
title,
model,
isAuthenticated,
projectId,
}: CreateChatInput) {
const supabase = await validateUserIdentity(userId, isAuthenticated)
if (!supabase) {
return {
id: crypto.randomUUID(),
user_id: userId,
title,
model,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
}
await checkUsageByModel(supabase, userId, model, isAuthenticated)
const insertData: {
user_id: string
title: string
model: string
project_id?: string
} = {
user_id: userId,
title: title || "New Chat",
model,
}
if (projectId) {
insertData.project_id = projectId
}
const { data, error } = await supabase
.from("chats")
.insert(insertData)
.select("*")
.single()
if (error || !data) {
console.error("Error creating chat:", error)
return null
}
return data
}
```
## /app/api/create-chat/route.ts
```ts path="/app/api/create-chat/route.ts"
import { createChatInDb } from "./api"
export async function POST(request: Request) {
try {
const { userId, title, model, isAuthenticated, projectId } =
await request.json()
if (!userId) {
return new Response(JSON.stringify({ error: "Missing userId" }), {
status: 400,
})
}
const chat = await createChatInDb({
userId,
title,
model,
isAuthenticated,
projectId,
})
if (!chat) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
return new Response(JSON.stringify({ chat }), { status: 200 })
} catch (err: unknown) {
console.error("Error in create-chat endpoint:", err)
if (err instanceof Error && err.message === "DAILY_LIMIT_REACHED") {
return new Response(
JSON.stringify({ error: err.message, code: "DAILY_LIMIT_REACHED" }),
{ status: 403 }
)
}
return new Response(
JSON.stringify({
error: (err as Error).message || "Internal server error",
}),
{ status: 500 }
)
}
}
```
## /app/api/create-guest/route.ts
```ts path="/app/api/create-guest/route.ts"
import { createGuestServerClient } from "@/lib/supabase/server-guest"
export async function POST(request: Request) {
try {
const { userId } = await request.json()
if (!userId) {
return new Response(JSON.stringify({ error: "Missing userId" }), {
status: 400,
})
}
const supabase = await createGuestServerClient()
if (!supabase) {
console.log("Supabase not enabled, skipping guest creation.")
return new Response(
JSON.stringify({ user: { id: userId, anonymous: true } }),
{
status: 200,
}
)
}
// Check if the user record already exists.
let { data: userData } = await supabase
.from("users")
.select("*")
.eq("id", userId)
.maybeSingle()
if (!userData) {
const { data, error } = await supabase
.from("users")
.insert({
id: userId,
email: `${userId}@anonymous.example`,
anonymous: true,
message_count: 0,
premium: false,
created_at: new Date().toISOString(),
})
.select("*")
.single()
if (error || !data) {
console.error("Error creating guest user:", error)
return new Response(
JSON.stringify({
error: "Failed to create guest user",
details: error?.message,
}),
{ status: 500 }
)
}
userData = data
}
return new Response(JSON.stringify({ user: userData }), { status: 200 })
} catch (err: unknown) {
console.error("Error in create-guest endpoint:", err)
return new Response(
JSON.stringify({ error: (err as Error).message || "Internal server error" }),
{ status: 500 }
)
}
}
```
## /app/api/csrf/route.ts
```ts path="/app/api/csrf/route.ts"
import { generateCsrfToken } from "@/lib/csrf"
import { cookies } from "next/headers"
import { NextResponse } from "next/server"
export async function GET() {
const token = generateCsrfToken()
const cookieStore = await cookies()
cookieStore.set("csrf_token", token, {
httpOnly: false,
secure: true,
path: "/",
})
return NextResponse.json({ ok: true })
}
```
## /app/api/health/route.ts
```ts path="/app/api/health/route.ts"
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json(
{
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
},
{ status: 200 }
)
}
```
## /app/api/models/route.ts
```ts path="/app/api/models/route.ts"
import {
getAllModels,
getModelsForUserProviders,
getModelsWithAccessFlags,
refreshModelsCache,
} from "@/lib/models"
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function GET() {
try {
const supabase = await createClient()
if (!supabase) {
const allModels = await getAllModels()
const models = allModels.map((model) => ({
...model,
accessible: true,
}))
return new Response(JSON.stringify({ models }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
})
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
const models = await getModelsWithAccessFlags()
return new Response(JSON.stringify({ models }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
})
}
const { data, error } = await supabase
.from("user_keys")
.select("provider")
.eq("user_id", authData.user.id)
if (error) {
console.error("Error fetching user keys:", error)
const models = await getModelsWithAccessFlags()
return new Response(JSON.stringify({ models }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
})
}
const userProviders = data?.map((k) => k.provider) || []
if (userProviders.length === 0) {
const models = await getModelsWithAccessFlags()
return new Response(JSON.stringify({ models }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
})
}
const models = await getModelsForUserProviders(userProviders)
return new Response(JSON.stringify({ models }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
})
} catch (error) {
console.error("Error fetching models:", error)
return new Response(JSON.stringify({ error: "Failed to fetch models" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
})
}
}
export async function POST() {
try {
refreshModelsCache()
const models = await getAllModels()
return NextResponse.json({
message: "Models cache refreshed",
models,
timestamp: new Date().toISOString(),
count: models.length,
})
} catch (error) {
console.error("Failed to refresh models:", error)
return NextResponse.json(
{ error: "Failed to refresh models" },
{ status: 500 }
)
}
}
```
## /app/api/projects/[projectId]/route.ts
```ts path="/app/api/projects/[projectId]/route.ts"
import { createClient } from "@/lib/supabase/server"
import { NextRequest, NextResponse } from "next/server"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const supabase = await createClient()
if (!supabase) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { data, error } = await supabase
.from("projects")
.select("*")
.eq("id", projectId)
.eq("user_id", authData.user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: "Project not found" }, { status: 404 })
}
return NextResponse.json(data)
} catch (err: unknown) {
console.error("Error in project endpoint:", err)
return new Response(
JSON.stringify({
error: (err as Error).message || "Internal server error",
}),
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const { name } = await request.json()
if (!name?.trim()) {
return NextResponse.json(
{ error: "Project name is required" },
{ status: 400 }
)
}
const supabase = await createClient()
if (!supabase) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { data, error } = await supabase
.from("projects")
.update({ name: name.trim() })
.eq("id", projectId)
.eq("user_id", authData.user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: "Project not found" }, { status: 404 })
}
return NextResponse.json(data)
} catch (err: unknown) {
console.error("Error updating project:", err)
return new Response(
JSON.stringify({
error: (err as Error).message || "Internal server error",
}),
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const supabase = await createClient()
if (!supabase) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// First verify the project exists and belongs to the user
const { data: project, error: fetchError } = await supabase
.from("projects")
.select("id")
.eq("id", projectId)
.eq("user_id", authData.user.id)
.single()
if (fetchError || !project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 })
}
// Delete the project (this will cascade delete related chats due to FK constraint)
const { error } = await supabase
.from("projects")
.delete()
.eq("id", projectId)
.eq("user_id", authData.user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err: unknown) {
console.error("Error deleting project:", err)
return new Response(
JSON.stringify({
error: (err as Error).message || "Internal server error",
}),
{ status: 500 }
)
}
}
```
## /app/api/projects/route.ts
```ts path="/app/api/projects/route.ts"
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
try {
const supabase = await createClient()
if (!supabase) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return new Response(JSON.stringify({ error: "Missing userId" }), {
status: 400,
})
}
const userId = authData.user.id
const { name } = await request.json()
const { data, error } = await supabase
.from("projects")
.insert({ name, user_id: userId })
.select()
.single()
if (error)
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
} catch (err: unknown) {
console.error("Error in projects endpoint:", err)
return new Response(
JSON.stringify({
error: (err as Error).message || "Internal server error",
}),
{ status: 500 }
)
}
}
export async function GET() {
const supabase = await createClient()
if (!supabase) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
const { data: authData } = await supabase.auth.getUser()
const userId = authData?.user?.id
if (!userId)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { data, error } = await supabase
.from("projects")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: true })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}
```
## /app/api/providers/route.ts
```ts path="/app/api/providers/route.ts"
import { createClient } from "@/lib/supabase/server"
import { getEffectiveApiKey, ProviderWithoutOllama } from "@/lib/user-keys"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
try {
const { provider, userId } = await request.json()
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Database not available" },
{ status: 500 }
)
}
const {
data: { user },
} = await supabase.auth.getUser()
if (!user || user.id !== userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Skip Ollama since it doesn't use API keys
if (provider === "ollama") {
return NextResponse.json({
hasUserKey: false,
provider,
})
}
const apiKey = await getEffectiveApiKey(
userId,
provider as ProviderWithoutOllama
)
const envKeyMap: Record<ProviderWithoutOllama, string | undefined> = {
openai: process.env.OPENAI_API_KEY,
mistral: process.env.MISTRAL_API_KEY,
perplexity: process.env.PERPLEXITY_API_KEY,
google: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
anthropic: process.env.ANTHROPIC_API_KEY,
xai: process.env.XAI_API_KEY,
openrouter: process.env.OPENROUTER_API_KEY,
}
return NextResponse.json({
hasUserKey:
!!apiKey && apiKey !== envKeyMap[provider as ProviderWithoutOllama],
provider,
})
} catch (error) {
console.error("Error checking provider keys:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/api/rate-limits/api.ts
```ts path="/app/api/rate-limits/api.ts"
import {
AUTH_DAILY_MESSAGE_LIMIT,
DAILY_LIMIT_PRO_MODELS,
NON_AUTH_DAILY_MESSAGE_LIMIT,
} from "@/lib/config"
import { validateUserIdentity } from "@/lib/server/api"
export async function getMessageUsage(
userId: string,
isAuthenticated: boolean
) {
const supabase = await validateUserIdentity(userId, isAuthenticated)
if (!supabase) return null
const { data, error } = await supabase
.from("users")
.select("daily_message_count, daily_pro_message_count")
.eq("id", userId)
.maybeSingle()
if (error || !data) {
throw new Error(error?.message || "Failed to fetch message usage")
}
const dailyLimit = isAuthenticated
? AUTH_DAILY_MESSAGE_LIMIT
: NON_AUTH_DAILY_MESSAGE_LIMIT
const dailyCount = data.daily_message_count || 0
const dailyProCount = data.daily_pro_message_count || 0
return {
dailyCount,
dailyProCount,
dailyLimit,
remaining: dailyLimit - dailyCount,
remainingPro: DAILY_LIMIT_PRO_MODELS - dailyProCount,
}
}
```
## /app/api/rate-limits/route.ts
```ts path="/app/api/rate-limits/route.ts"
import { getMessageUsage } from "./api"
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const userId = searchParams.get("userId")
const isAuthenticated = searchParams.get("isAuthenticated") === "true"
if (!userId) {
return new Response(JSON.stringify({ error: "Missing userId" }), {
status: 400,
})
}
try {
const usage = await getMessageUsage(userId, isAuthenticated)
if (!usage) {
return new Response(
JSON.stringify({ error: "Supabase not available in this deployment." }),
{ status: 200 }
)
}
return new Response(JSON.stringify(usage), { status: 200 })
} catch (err: unknown) {
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 })
}
}
```
## /app/api/toggle-chat-pin/route.ts
```ts path="/app/api/toggle-chat-pin/route.ts"
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
try {
const supabase = await createClient()
const { chatId, pinned } = await request.json()
if (!chatId || typeof pinned !== "boolean") {
return NextResponse.json(
{ error: "Missing chatId or pinned" },
{ status: 400 }
)
}
if (!supabase) {
return NextResponse.json({ success: true }, { status: 200 })
}
const toggle = pinned
? { pinned: true, pinned_at: new Date().toISOString() }
: { pinned: false, pinned_at: null }
const { error } = await supabase
.from("chats")
.update(toggle)
.eq("id", chatId)
if (error) {
return NextResponse.json(
{ error: "Failed to update pinned" },
{ status: 500 }
)
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("toggle-chat-pin unhandled error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/api/update-chat-model/route.ts
```ts path="/app/api/update-chat-model/route.ts"
import { createClient } from "@/lib/supabase/server"
export async function POST(request: Request) {
try {
const supabase = await createClient()
const { chatId, model } = await request.json()
if (!chatId || !model) {
return new Response(
JSON.stringify({ error: "Missing chatId or model" }),
{ status: 400 }
)
}
// If Supabase is not available, we still return success
if (!supabase) {
console.log("Supabase not enabled, skipping DB update")
return new Response(JSON.stringify({ success: true }), { status: 200 })
}
const { error } = await supabase
.from("chats")
.update({ model })
.eq("id", chatId)
if (error) {
console.error("Error updating chat model:", error)
return new Response(
JSON.stringify({
error: "Failed to update chat model",
details: error.message,
}),
{ status: 500 }
)
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
})
} catch (err: unknown) {
console.error("Error in update-chat-model endpoint:", err)
return new Response(
JSON.stringify({ error: (err as Error).message || "Internal server error" }),
{ status: 500 }
)
}
}
```
## /app/api/user-key-status/route.ts
```ts path="/app/api/user-key-status/route.ts"
import { PROVIDERS } from "@/lib/providers"
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
const SUPPORTED_PROVIDERS = PROVIDERS.map((p) => p.id)
export async function GET() {
try {
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Supabase not available" },
{ status: 500 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { data, error } = await supabase
.from("user_keys")
.select("provider")
.eq("user_id", authData.user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Create status object for all supported providers
const userProviders = data?.map((k) => k.provider) || []
const providerStatus = SUPPORTED_PROVIDERS.reduce(
(acc, provider) => {
acc[provider] = userProviders.includes(provider)
return acc
},
{} as Record<string, boolean>
)
return NextResponse.json(providerStatus)
} catch (err) {
console.error("Key status error:", err)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/api/user-keys/route.ts
```ts path="/app/api/user-keys/route.ts"
import { encryptKey } from "@/lib/encryption"
import { getModelsForProvider } from "@/lib/models"
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
try {
const { provider, apiKey } = await request.json()
if (!provider || !apiKey) {
return NextResponse.json(
{ error: "Provider and API key are required" },
{ status: 400 }
)
}
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Supabase not available" },
{ status: 500 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { encrypted, iv } = encryptKey(apiKey)
// Check if this is a new API key (not an update)
const { data: existingKey } = await supabase
.from("user_keys")
.select("provider")
.eq("user_id", authData.user.id)
.eq("provider", provider)
.single()
const isNewKey = !existingKey
// Save the API key
const { error } = await supabase.from("user_keys").upsert({
user_id: authData.user.id,
provider,
encrypted_key: encrypted,
iv,
updated_at: new Date().toISOString(),
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If this is a new API key, add provider models to favorites
if (isNewKey) {
try {
// Get current user's favorite models
const { data: userData } = await supabase
.from("users")
.select("favorite_models")
.eq("id", authData.user.id)
.single()
const currentFavorites = userData?.favorite_models || []
// Get models for this provider
const providerModels = await getModelsForProvider(provider)
const providerModelIds = providerModels.map((model) => model.id)
// Skip if no models found for this provider
if (providerModelIds.length === 0) {
return NextResponse.json({
success: true,
isNewKey,
message: "API key saved",
})
}
// Add provider models to favorites (only if not already there)
const newModelsToAdd = providerModelIds.filter(
(modelId) => !currentFavorites.includes(modelId)
)
if (newModelsToAdd.length > 0) {
const updatedFavorites = [...currentFavorites, ...newModelsToAdd]
// Update user's favorite models
const { error: favoritesError } = await supabase
.from("users")
.update({ favorite_models: updatedFavorites })
.eq("id", authData.user.id)
if (favoritesError) {
console.error("Failed to update favorite models:", favoritesError)
}
}
} catch (modelsError) {
console.error("Failed to update favorite models:", modelsError)
// Don't fail the main request if favorite models update fails
}
}
return NextResponse.json({
success: true,
isNewKey,
message: isNewKey
? `API key saved and ${provider} models added to favorites`
: "API key updated",
})
} catch (error) {
console.error("Error in POST /api/user-keys:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function DELETE(request: Request) {
try {
const { provider } = await request.json()
if (!provider) {
return NextResponse.json(
{ error: "Provider is required" },
{ status: 400 }
)
}
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Supabase not available" },
{ status: 500 }
)
}
const { data: authData } = await supabase.auth.getUser()
if (!authData?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { error } = await supabase
.from("user_keys")
.delete()
.eq("user_id", authData.user.id)
.eq("provider", provider)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error in DELETE /api/user-keys:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/api/user-preferences/favorite-models/route.ts
```ts path="/app/api/user-preferences/favorite-models/route.ts"
import { createClient } from "@/lib/supabase/server"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
try {
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Database connection failed" },
{ status: 500 }
)
}
// Get the current user
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Parse the request body
const body = await request.json()
const { favorite_models } = body
// Validate the favorite_models array
if (!Array.isArray(favorite_models)) {
return NextResponse.json(
{ error: "favorite_models must be an array" },
{ status: 400 }
)
}
// Validate that all items in the array are strings
if (!favorite_models.every((model) => typeof model === "string")) {
return NextResponse.json(
{ error: "All favorite_models must be strings" },
{ status: 400 }
)
}
// Update the user's favorite models
const { data, error } = await supabase
.from("users")
.update({
favorite_models,
})
.eq("id", user.id)
.select("favorite_models")
.single()
if (error) {
console.error("Error updating favorite models:", error)
return NextResponse.json(
{ error: "Failed to update favorite models" },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
favorite_models: data.favorite_models,
})
} catch (error) {
console.error("Error in favorite-models API:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function GET() {
try {
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Database connection failed" },
{ status: 500 }
)
}
// Get the current user
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Get the user's favorite models
const { data, error } = await supabase
.from("users")
.select("favorite_models")
.eq("id", user.id)
.single()
if (error) {
console.error("Error fetching favorite models:", error)
return NextResponse.json(
{ error: "Failed to fetch favorite models" },
{ status: 500 }
)
}
return NextResponse.json({
favorite_models: data.favorite_models || [],
})
} catch (error) {
console.error("Error in favorite-models GET API:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/api/user-preferences/route.ts
```ts path="/app/api/user-preferences/route.ts"
import { createClient } from "@/lib/supabase/server"
import { NextRequest, NextResponse } from "next/server"
export async function GET() {
try {
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Database connection failed" },
{ status: 500 }
)
}
// Get the current user
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Get the user's preferences
const { data, error } = await supabase
.from("user_preferences")
.select("*")
.eq("user_id", user.id)
.single()
if (error) {
// If no preferences exist, return defaults
if (error.code === "PGRST116") {
return NextResponse.json({
layout: "fullscreen",
prompt_suggestions: true,
show_tool_invocations: true,
show_conversation_previews: true,
multi_model_enabled: false,
hidden_models: [],
})
}
console.error("Error fetching user preferences:", error)
return NextResponse.json(
{ error: "Failed to fetch user preferences" },
{ status: 500 }
)
}
return NextResponse.json({
layout: data.layout,
prompt_suggestions: data.prompt_suggestions,
show_tool_invocations: data.show_tool_invocations,
show_conversation_previews: data.show_conversation_previews,
multi_model_enabled: data.multi_model_enabled,
hidden_models: data.hidden_models || [],
})
} catch (error) {
console.error("Error in user-preferences GET API:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest) {
try {
const supabase = await createClient()
if (!supabase) {
return NextResponse.json(
{ error: "Database connection failed" },
{ status: 500 }
)
}
// Get the current user
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Parse the request body
const body = await request.json()
const {
layout,
prompt_suggestions,
show_tool_invocations,
show_conversation_previews,
multi_model_enabled,
hidden_models,
} = body
// Validate the data types
if (layout && typeof layout !== "string") {
return NextResponse.json(
{ error: "layout must be a string" },
{ status: 400 }
)
}
if (hidden_models && !Array.isArray(hidden_models)) {
return NextResponse.json(
{ error: "hidden_models must be an array" },
{ status: 400 }
)
}
// Prepare update object with only provided fields
const updateData: any = {}
if (layout !== undefined) updateData.layout = layout
if (prompt_suggestions !== undefined)
updateData.prompt_suggestions = prompt_suggestions
if (show_tool_invocations !== undefined)
updateData.show_tool_invocations = show_tool_invocations
if (show_conversation_previews !== undefined)
updateData.show_conversation_previews = show_conversation_previews
if (multi_model_enabled !== undefined)
updateData.multi_model_enabled = multi_model_enabled
if (hidden_models !== undefined) updateData.hidden_models = hidden_models
// Try to update first, then insert if doesn't exist
const { data, error } = await supabase
.from("user_preferences")
.upsert(
{
user_id: user.id,
...updateData,
},
{
onConflict: "user_id",
}
)
.select("*")
.single()
if (error) {
console.error("Error updating user preferences:", error)
return NextResponse.json(
{ error: "Failed to update user preferences" },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
layout: data.layout,
prompt_suggestions: data.prompt_suggestions,
show_tool_invocations: data.show_tool_invocations,
show_conversation_previews: data.show_conversation_previews,
multi_model_enabled: data.multi_model_enabled,
hidden_models: data.hidden_models || [],
})
} catch (error) {
console.error("Error in user-preferences PUT API:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
```
## /app/auth/callback/route.ts
```ts path="/app/auth/callback/route.ts"
import { MODEL_DEFAULT } from "@/lib/config"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { createClient } from "@/lib/supabase/server"
import { createGuestServerClient } from "@/lib/supabase/server-guest"
import { NextResponse } from "next/server"
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get("code")
const next = searchParams.get("next") ?? "/"
if (!isSupabaseEnabled) {
return NextResponse.redirect(
`${origin}/auth/error?message=${encodeURIComponent("Supabase is not enabled in this deployment.")}`
)
}
if (!code) {
return NextResponse.redirect(
`${origin}/auth/error?message=${encodeURIComponent("Missing authentication code")}`
)
}
const supabase = await createClient()
const supabaseAdmin = await createGuestServerClient()
if (!supabase || !supabaseAdmin) {
return NextResponse.redirect(
`${origin}/auth/error?message=${encodeURIComponent("Supabase is not enabled in this deployment.")}`
)
}
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
console.error("Auth error:", error)
return NextResponse.redirect(
`${origin}/auth/error?message=${encodeURIComponent(error.message)}`
)
}
const user = data?.user
if (!user || !user.id || !user.email) {
return NextResponse.redirect(
`${origin}/auth/error?message=${encodeURIComponent("Missing user info")}`
)
}
try {
// Try to insert user only if not exists
const { error: insertError } = await supabaseAdmin.from("users").insert({
id: user.id,
email: user.email,
created_at: new Date().toISOString(),
message_count: 0,
premium: false,
favorite_models: [MODEL_DEFAULT],
})
if (insertError && insertError.code !== "23505") {
console.error("Error inserting user:", insertError)
}
} catch (err) {
console.error("Unexpected user insert error:", err)
}
const host = request.headers.get("host")
const protocol = host?.includes("localhost") ? "http" : "https"
const redirectUrl = `${protocol}://${host}${next}`
return NextResponse.redirect(redirectUrl)
}
```
## /app/auth/error/page.tsx
```tsx path="/app/auth/error/page.tsx"
"use client"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "@phosphor-icons/react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
export const dynamic = "force-dynamic"
// Create a separate component that uses useSearchParams
function AuthErrorContent() {
const searchParams = useSearchParams()
const message =
searchParams.get("message") || "An error occurred during authentication."
return (
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-medium tracking-tight text-white sm:text-4xl">
Authentication Error
</h1>
<div className="mt-6 rounded-md bg-red-500/10 p-4">
<p className="text-red-400">{message}</p>
</div>
<div className="mt-8">
<Button
variant="secondary"
className="w-full text-base sm:text-base"
size="lg"
asChild
>
<Link href="/auth">Try Again</Link>
</Button>
</div>
</div>
</div>
)
}
export default function AuthErrorPage() {
return (
<div className="flex h-screen flex-col bg-zinc-800 text-white">
{/* Header */}
<header className="p-4">
<Link
href="/"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-white hover:bg-zinc-700"
>
<ArrowLeft className="size-5 text-white" />
<span className="font-base ml-2 hidden text-sm sm:inline-block">
Back to Chat
</span>
</Link>
</header>
<main className="flex flex-1 flex-col items-center justify-center px-4 sm:px-6">
<Suspense fallback={<div>Loading...</div>}>
<AuthErrorContent />
</Suspense>
</main>
<footer className="py-6 text-center text-sm text-zinc-500">
<p>
Need help? {/* @todo */}
<Link href="/" className="text-zinc-400 hover:underline">
Contact Support
</Link>
</p>
</footer>
</div>
)
}
```
## /app/auth/login-page.tsx
```tsx path="/app/auth/login-page.tsx"
"use client"
import { Button } from "@/components/ui/button"
import { signInWithGoogle } from "@/lib/api"
import { createClient } from "@/lib/supabase/client"
import Image from "next/image"
import Link from "next/link"
import { useState } from "react"
import { HeaderGoBack } from "../components/header-go-back"
export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSignInWithGoogle() {
const supabase = createClient()
if (!supabase) {
throw new Error("Supabase is not configured")
}
try {
setIsLoading(true)
setError(null)
const data = await signInWithGoogle(supabase)
// Redirect to the provider URL
if (data?.url) {
window.location.href = data.url
}
} catch (err: unknown) {
console.error("Error signing in with Google:", err)
setError(
(err as Error).message ||
"An unexpected error occurred. Please try again."
)
} finally {
setIsLoading(false)
}
}
return (
<div className="bg-background flex h-dvh w-full flex-col">
<HeaderGoBack href="/" />
<main className="flex flex-1 flex-col items-center justify-center px-4 sm:px-6">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-foreground text-3xl font-medium tracking-tight sm:text-4xl">
Welcome to Zola
</h1>
<p className="text-muted-foreground mt-3">
Sign in below to increase your message limits.
</p>
</div>
{error && (
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{error}
</div>
)}
<div className="mt-8">
<Button
variant="secondary"
className="w-full text-base sm:text-base"
size="lg"
onClick={handleSignInWithGoogle}
disabled={isLoading}
>
<img
src="https://www.google.com/favicon.ico"
alt="Google logo"
width={20}
height={20}
className="mr-2 size-4"
/>
<span>
{isLoading ? "Connecting..." : "Continue with Google"}
</span>
</Button>
</div>
</div>
</main>
<footer className="text-muted-foreground py-6 text-center text-sm">
{/* @todo */}
<p>
By continuing, you agree to our{" "}
<Link href="/" className="text-foreground hover:underline">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/" className="text-foreground hover:underline">
Privacy Policy
</Link>
</p>
</footer>
</div>
)
}
```
## /app/auth/login/actions.ts
```ts path="/app/auth/login/actions.ts"
"use server"
import { toast } from "@/components/ui/toast"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { createClient } from "@/lib/supabase/server"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function signOut() {
if (!isSupabaseEnabled) {
toast({
title: "Sign out is not supported in this deployment",
status: "info",
})
return
}
const supabase = await createClient()
if (!supabase) {
toast({
title: "Sign out is not supported in this deployment",
status: "info",
})
return
}
await supabase.auth.signOut()
revalidatePath("/", "layout")
redirect("/auth/login")
}
```
## /app/auth/page.tsx
```tsx path="/app/auth/page.tsx"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { notFound } from "next/navigation"
import LoginPage from "./login-page"
export default function AuthPage() {
if (!isSupabaseEnabled) {
return notFound()
}
return <LoginPage />
}
```
## /app/c/[chatId]/page.tsx
```tsx path="/app/c/[chatId]/page.tsx"
import { ChatContainer } from "@/app/components/chat/chat-container"
import { LayoutApp } from "@/app/components/layout/layout-app"
import { MessagesProvider } from "@/lib/chat-store/messages/provider"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { createClient } from "@/lib/supabase/server"
import { redirect } from "next/navigation"
export default async function Page() {
if (isSupabaseEnabled) {
const supabase = await createClient()
if (supabase) {
const { data: userData, error: userError } = await supabase.auth.getUser()
if (userError || !userData?.user) {
redirect("/")
}
}
}
return (
<MessagesProvider>
<LayoutApp>
<ChatContainer />
</LayoutApp>
</MessagesProvider>
)
}
```
## /app/components/chat-input/button-file-upload.tsx
```tsx path="/app/components/chat-input/button-file-upload.tsx"
import {
FileUpload,
FileUploadContent,
FileUploadTrigger,
} from "@/components/prompt-kit/file-upload"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { getModelInfo } from "@/lib/models"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { cn } from "@/lib/utils"
import { FileArrowUp, Paperclip } from "@phosphor-icons/react"
import React from "react"
import { PopoverContentAuth } from "./popover-content-auth"
type ButtonFileUploadProps = {
onFileUpload: (files: File[]) => void
isUserAuthenticated: boolean
model: string
}
export function ButtonFileUpload({
onFileUpload,
isUserAuthenticated,
model,
}: ButtonFileUploadProps) {
if (!isSupabaseEnabled) {
return null
}
const isFileUploadAvailable = getModelInfo(model)?.vision
if (!isFileUploadAvailable) {
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="sm"
variant="secondary"
className="border-border dark:bg-secondary size-9 rounded-full border bg-transparent"
type="button"
aria-label="Add files"
>
<Paperclip className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Add files</TooltipContent>
</Tooltip>
<PopoverContent className="p-2">
<div className="text-secondary-foreground text-sm">
This model does not support file uploads.
<br />
Please select another model.
</div>
</PopoverContent>
</Popover>
)
}
if (!isUserAuthenticated) {
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="sm"
variant="secondary"
className="border-border dark:bg-secondary size-9 rounded-full border bg-transparent"
type="button"
aria-label="Add files"
>
<Paperclip className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Add files</TooltipContent>
</Tooltip>
<PopoverContentAuth />
</Popover>
)
}
return (
<FileUpload
onFilesAdded={onFileUpload}
multiple
disabled={!isUserAuthenticated}
accept=".txt,.md,image/jpeg,image/png,image/gif,image/webp,image/svg,image/heic,image/heif"
>
<Tooltip>
<TooltipTrigger asChild>
<FileUploadTrigger asChild>
<Button
size="sm"
variant="secondary"
className={cn(
"border-border dark:bg-secondary size-9 rounded-full border bg-transparent",
!isUserAuthenticated && "opacity-50"
)}
type="button"
disabled={!isUserAuthenticated}
aria-label="Add files"
>
<Paperclip className="size-4" />
</Button>
</FileUploadTrigger>
</TooltipTrigger>
<TooltipContent>Add files</TooltipContent>
</Tooltip>
<FileUploadContent>
<div className="border-input bg-background flex flex-col items-center rounded-lg border border-dashed p-8">
<FileArrowUp className="text-muted-foreground size-8" />
<span className="mt-4 mb-1 text-lg font-medium">Drop files here</span>
<span className="text-muted-foreground text-sm">
Drop any files here to add it to the conversation
</span>
</div>
</FileUploadContent>
</FileUpload>
)
}
```
## /app/components/chat-input/button-search.tsx
```tsx path="/app/components/chat-input/button-search.tsx"
import { Button } from "@/components/ui/button"
import { Popover, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { GlobeIcon } from "@phosphor-icons/react"
import React from "react"
import { PopoverContentAuth } from "./popover-content-auth"
type ButtonSearchProps = {
isSelected?: boolean
onToggle?: (isSelected: boolean) => void
isAuthenticated: boolean
}
export function ButtonSearch({
isSelected = false,
onToggle,
isAuthenticated,
}: ButtonSearchProps) {
const handleClick = () => {
const newState = !isSelected
onToggle?.(newState)
}
if (!isAuthenticated) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
className="border-border dark:bg-secondary rounded-full border bg-transparent"
>
<GlobeIcon className="size-5" />
Search
</Button>
</PopoverTrigger>
<PopoverContentAuth />
</Popover>
)
}
return (
<Button
variant="secondary"
className={cn(
"border-border dark:bg-secondary rounded-full border bg-transparent transition-all duration-150 has-[>svg]:px-1.75 md:has-[>svg]:px-3",
isSelected &&
"border-[#0091FF]/20 bg-[#E5F3FE] text-[#0091FF] hover:bg-[#E5F3FE] hover:text-[#0091FF]"
)}
onClick={handleClick}
>
<GlobeIcon className="size-5" />
<span className="hidden md:block">Search</span>
</Button>
)
}
```
## /app/components/chat-input/chat-input.tsx
```tsx path="/app/components/chat-input/chat-input.tsx"
"use client"
import { ModelSelector } from "@/components/common/model-selector/base"
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from "@/components/prompt-kit/prompt-input"
import { Button } from "@/components/ui/button"
import { getModelInfo } from "@/lib/models"
import { ArrowUpIcon, StopIcon } from "@phosphor-icons/react"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { PromptSystem } from "../suggestions/prompt-system"
import { ButtonFileUpload } from "./button-file-upload"
import { ButtonSearch } from "./button-search"
import { FileList } from "./file-list"
type ChatInputProps = {
value: string
onValueChange: (value: string) => void
onSend: () => void
isSubmitting?: boolean
hasMessages?: boolean
files: File[]
onFileUpload: (files: File[]) => void
onFileRemove: (file: File) => void
onSuggestion: (suggestion: string) => void
hasSuggestions?: boolean
onSelectModel: (model: string) => void
selectedModel: string
isUserAuthenticated: boolean
stop: () => void
status?: "submitted" | "streaming" | "ready" | "error"
setEnableSearch: (enabled: boolean) => void
enableSearch: boolean
quotedText?: { text: string; messageId: string } | null
}
export function ChatInput({
value,
onValueChange,
onSend,
isSubmitting,
files,
onFileUpload,
onFileRemove,
onSuggestion,
hasSuggestions,
onSelectModel,
selectedModel,
isUserAuthenticated,
stop,
status,
setEnableSearch,
enableSearch,
quotedText,
}: ChatInputProps) {
const selectModelConfig = getModelInfo(selectedModel)
const hasSearchSupport = Boolean(selectModelConfig?.webSearch)
const isOnlyWhitespace = (text: string) => !/[^\s]/.test(text)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = useCallback(() => {
if (isSubmitting) {
return
}
if (status === "streaming") {
stop()
return
}
onSend()
}, [isSubmitting, onSend, status, stop])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isSubmitting) {
e.preventDefault()
return
}
if (e.key === "Enter" && status === "streaming") {
e.preventDefault()
return
}
if (e.key === "Enter" && !e.shiftKey) {
if (isOnlyWhitespace(value)) {
return
}
e.preventDefault()
onSend()
}
},
[isSubmitting, onSend, status, value]
)
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
const hasImageContent = Array.from(items).some((item) =>
item.type.startsWith("image/")
)
if (!isUserAuthenticated && hasImageContent) {
e.preventDefault()
return
}
if (isUserAuthenticated && hasImageContent) {
const imageFiles: File[] = []
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile()
if (file) {
const newFile = new File(
[file],
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
{ type: file.type }
)
imageFiles.push(newFile)
}
}
}
if (imageFiles.length > 0) {
onFileUpload(imageFiles)
}
}
// Text pasting will work by default for everyone
},
[isUserAuthenticated, onFileUpload]
)
useEffect(() => {
if (quotedText) {
const quoted = quotedText.text
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
onValueChange(value ? `${value}\n\n${quoted}\n\n` : `${quoted}\n\n`)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quotedText, onValueChange])
useMemo(() => {
if (!hasSearchSupport && enableSearch) {
setEnableSearch?.(false)
}
}, [hasSearchSupport, enableSearch, setEnableSearch])
return (
<div className="relative flex w-full flex-col gap-4">
{hasSuggestions && (
<PromptSystem
onValueChange={onValueChange}
onSuggestion={onSuggestion}
value={value}
/>
)}
<div
className="relative order-2 px-2 pb-3 sm:pb-4 md:order-1"
onClick={() => textareaRef.current?.focus()}
>
<PromptInput
className="bg-popover relative z-10 p-0 pt-1 shadow-xs backdrop-blur-xl"
maxHeight={200}
value={value}
onValueChange={onValueChange}
>
<FileList files={files} onFileRemove={onFileRemove} />
<PromptInputTextarea
ref={textareaRef}
placeholder="Ask Zola"
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="min-h-[44px] pt-3 pl-4 text-base leading-[1.3] sm:text-base md:text-base"
/>
<PromptInputActions className="mt-3 w-full justify-between p-2">
<div className="flex gap-2">
<ButtonFileUpload
onFileUpload={onFileUpload}
isUserAuthenticated={isUserAuthenticated}
model={selectedModel}
/>
<ModelSelector
selectedModelId={selectedModel}
setSelectedModelId={onSelectModel}
isUserAuthenticated={isUserAuthenticated}
className="rounded-full"
/>
{hasSearchSupport ? (
<ButtonSearch
isSelected={enableSearch}
onToggle={setEnableSearch}
isAuthenticated={isUserAuthenticated}
/>
) : null}
</div>
<PromptInputAction
tooltip={status === "streaming" ? "Stop" : "Send"}
>
<Button
size="sm"
className="size-9 rounded-full transition-all duration-300 ease-out"
disabled={!value || isSubmitting || isOnlyWhitespace(value)}
type="button"
onClick={handleSend}
aria-label={status === "streaming" ? "Stop" : "Send message"}
>
{status === "streaming" ? (
<StopIcon className="size-4" />
) : (
<ArrowUpIcon className="size-4" />
)}
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
</div>
</div>
)
}
```
## /app/components/chat-input/file-items.tsx
```tsx path="/app/components/chat-input/file-items.tsx"
"use client"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { X } from "@phosphor-icons/react"
import Image from "next/image"
import { useState } from "react"
type FileItemProps = {
file: File
onRemove: (file: File) => void
}
export function FileItem({ file, onRemove }: FileItemProps) {
const [isRemoving, setIsRemoving] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const handleRemove = () => {
setIsRemoving(true)
onRemove(file)
}
return (
<div className="relative mr-2 mb-0 flex items-center">
<HoverCard
open={file.type.includes("image") ? isOpen : false}
onOpenChange={setIsOpen}
>
<HoverCardTrigger className="w-full">
<div className="bg-background hover:bg-accent border-input flex w-full items-center gap-3 rounded-2xl border p-2 pr-3 transition-colors">
<div className="bg-accent-foreground flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-md">
{file.type.includes("image") ? (
<Image
src={URL.createObjectURL(file)}
alt={file.name}
width={40}
height={40}
className="h-full w-full object-cover"
/>
) : (
<div className="text-center text-xs text-gray-400">
{file.name.split(".").pop()?.toUpperCase()}
</div>
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="truncate text-xs font-medium">{file.name}</span>
<span className="text-xs text-gray-500">
{(file.size / 1024).toFixed(2)}kB
</span>
</div>
</div>
</HoverCardTrigger>
<HoverCardContent side="top">
<Image
src={URL.createObjectURL(file)}
alt={file.name}
width={200}
height={200}
className="h-full w-full object-cover"
/>
</HoverCardContent>
</HoverCard>
{!isRemoving ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleRemove}
className="border-background absolute top-1 right-1 z-10 inline-flex size-6 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-[3px] bg-black text-white shadow-none transition-colors"
aria-label="Remove file"
>
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove file</TooltipContent>
</Tooltip>
) : null}
</div>
)
}
```
## /app/components/chat-input/file-list.tsx
```tsx path="/app/components/chat-input/file-list.tsx"
import { AnimatePresence, motion } from "motion/react"
import { FileItem } from "./file-items"
type FileListProps = {
files: File[]
onFileRemove: (file: File) => void
}
const TRANSITION = {
type: "spring",
duration: 0.2,
bounce: 0,
}
export function FileList({ files, onFileRemove }: FileListProps) {
return (
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.div
key="files-list"
initial={{ height: 0 }}
animate={{ height: "auto" }}
exit={{ height: 0 }}
transition={TRANSITION}
className="overflow-hidden"
>
<div className="flex flex-row overflow-x-auto pl-3">
<AnimatePresence initial={false}>
{files.map((file) => (
<motion.div
key={file.name}
initial={{ width: 0 }}
animate={{ width: 180 }}
exit={{ width: 0 }}
transition={TRANSITION}
className="relative shrink-0 overflow-hidden pt-2"
>
<FileItem
key={file.name}
file={file}
onRemove={onFileRemove}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
```
## /app/components/chat-input/popover-content-auth.tsx
```tsx path="/app/components/chat-input/popover-content-auth.tsx"
"use client"
import { Button } from "@/components/ui/button"
import { PopoverContent } from "@/components/ui/popover"
import { signInWithGoogle } from "@/lib/api"
import { APP_NAME } from "@/lib/config"
import { createClient } from "@/lib/supabase/client"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import Image from "next/image"
import { useState } from "react"
export function PopoverContentAuth() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isSupabaseEnabled) {
return null
}
const handleSignInWithGoogle = async () => {
const supabase = createClient()
if (!supabase) {
throw new Error("Supabase is not configured")
}
try {
setIsLoading(true)
setError(null)
const data = await signInWithGoogle(supabase)
// Redirect to the provider URL
if (data?.url) {
window.location.href = data.url
}
} catch (err: unknown) {
console.error("Error signing in with Google:", err)
setError(
(err as Error).message ||
"An unexpected error occurred. Please try again."
)
} finally {
setIsLoading(false)
}
}
return (
<PopoverContent
className="w-[300px] overflow-hidden rounded-xl p-0"
side="top"
align="start"
>
<Image
src="/banner_forest.jpg"
alt={`calm paint generate by ${APP_NAME}`}
width={300}
height={128}
className="h-32 w-full object-cover"
/>
{error && (
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{error}
</div>
)}
<div className="p-3">
<p className="text-primary mb-1 text-base font-medium">
Login to try more features for free
</p>
<p className="text-muted-foreground mb-5 text-base">
Add files, use more models, BYOK, and more.
</p>
<Button
variant="secondary"
className="w-full text-base"
size="lg"
onClick={handleSignInWithGoogle}
disabled={isLoading}
>
<img
src="https://www.google.com/favicon.ico"
alt="Google logo"
width={20}
height={20}
className="mr-2 size-4"
/>
<span>{isLoading ? "Connecting..." : "Continue with Google"}</span>
</Button>
</div>
</PopoverContent>
)
}
```
## /app/components/chat-input/suggestions.tsx
```tsx path="/app/components/chat-input/suggestions.tsx"
"use client"
import { PromptSuggestion } from "@/components/prompt-kit/prompt-suggestion"
import { TRANSITION_SUGGESTIONS } from "@/lib/motion"
import { AnimatePresence, motion } from "motion/react"
import React, { memo, useCallback, useMemo, useState } from "react"
import { SUGGESTIONS as SUGGESTIONS_CONFIG } from "../../../lib/config"
type SuggestionsProps = {
onValueChange: (value: string) => void
onSuggestion: (suggestion: string) => void
value?: string
}
const MotionPromptSuggestion = motion.create(PromptSuggestion)
export const Suggestions = memo(function Suggestions({
onValueChange,
onSuggestion,
value,
}: SuggestionsProps) {
const [activeCategory, setActiveCategory] = useState<string | null>(null)
if (!value && activeCategory !== null) {
setActiveCategory(null)
}
const activeCategoryData = SUGGESTIONS_CONFIG.find(
(group) => group.label === activeCategory
)
const showCategorySuggestions =
activeCategoryData && activeCategoryData.items.length > 0
const handleSuggestionClick = useCallback(
(suggestion: string) => {
setActiveCategory(null)
onSuggestion(suggestion)
onValueChange("")
},
[onSuggestion, onValueChange]
)
const handleCategoryClick = useCallback(
(suggestion: { label: string; prompt: string }) => {
setActiveCategory(suggestion.label)
onValueChange(suggestion.prompt)
},
[onValueChange]
)
const suggestionsGrid = useMemo(
() => (
<motion.div
key="suggestions-grid"
className="flex w-full max-w-full flex-nowrap justify-start gap-2 overflow-x-auto px-2 md:mx-auto md:max-w-2xl md:flex-wrap md:justify-center md:pl-0"
initial="initial"
animate="animate"
variants={{
initial: { opacity: 0, y: 10, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
transition={TRANSITION_SUGGESTIONS}
style={{
scrollbarWidth: "none",
}}
>
{SUGGESTIONS_CONFIG.map((suggestion, index) => (
<MotionPromptSuggestion
key={suggestion.label}
onClick={() => handleCategoryClick(suggestion)}
className="capitalize"
initial="initial"
animate="animate"
transition={{
...TRANSITION_SUGGESTIONS,
delay: index * 0.02,
}}
variants={{
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
}}
>
<suggestion.icon className="size-4" />
{suggestion.label}
</MotionPromptSuggestion>
))}
</motion.div>
),
[handleCategoryClick]
)
const suggestionsList = useMemo(
() => (
<motion.div
className="flex w-full flex-col space-y-1 px-2"
key={activeCategoryData?.label}
initial="initial"
animate="animate"
variants={{
initial: { opacity: 0, y: 10, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: {
opacity: 0,
y: -10,
filter: "blur(4px)",
},
}}
transition={TRANSITION_SUGGESTIONS}
>
{activeCategoryData?.items.map((suggestion: string, index: number) => (
<MotionPromptSuggestion
key={`${activeCategoryData?.label}-${suggestion}-${index}`}
highlight={activeCategoryData.highlight}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className="block h-full text-left"
initial="initial"
animate="animate"
variants={{
initial: { opacity: 0, y: -10 },
animate: { opacity: 1, y: 0 },
}}
transition={{
...TRANSITION_SUGGESTIONS,
delay: index * 0.05,
}}
>
{suggestion}
</MotionPromptSuggestion>
))}
</motion.div>
),
[
handleSuggestionClick,
activeCategoryData?.highlight,
activeCategoryData?.items,
activeCategoryData?.label,
]
)
return (
<AnimatePresence mode="wait">
{showCategorySuggestions ? suggestionsList : suggestionsGrid}
</AnimatePresence>
)
})
```
## /app/components/chat/chat-container.tsx
```tsx path="/app/components/chat/chat-container.tsx"
"use client"
import { MultiChat } from "@/app/components/multi-chat/multi-chat"
import { useUserPreferences } from "@/lib/user-preference-store/provider"
import { Chat } from "./chat"
export function ChatContainer() {
const { preferences } = useUserPreferences()
const multiModelEnabled = preferences.multiModelEnabled
if (multiModelEnabled) {
return <MultiChat />
}
return <Chat />
}
```
## /app/components/chat/chat.tsx
```tsx path="/app/components/chat/chat.tsx"
"use client"
import { ChatInput } from "@/app/components/chat-input/chat-input"
import { Conversation } from "@/app/components/chat/conversation"
import { useModel } from "@/app/components/chat/use-model"
import { useChatDraft } from "@/app/hooks/use-chat-draft"
import { useChats } from "@/lib/chat-store/chats/provider"
import { useMessages } from "@/lib/chat-store/messages/provider"
import { useChatSession } from "@/lib/chat-store/session/provider"
import { SYSTEM_PROMPT_DEFAULT } from "@/lib/config"
import { useUserPreferences } from "@/lib/user-preference-store/provider"
import { useUser } from "@/lib/user-store/provider"
import { cn } from "@/lib/utils"
import { AnimatePresence, motion } from "motion/react"
import dynamic from "next/dynamic"
import { redirect } from "next/navigation"
import { useCallback, useMemo, useState } from "react"
import { useChatCore } from "./use-chat-core"
import { useChatOperations } from "./use-chat-operations"
import { useFileUpload } from "./use-file-upload"
const FeedbackWidget = dynamic(
() => import("./feedback-widget").then((mod) => mod.FeedbackWidget),
{ ssr: false }
)
const DialogAuth = dynamic(
() => import("./dialog-auth").then((mod) => mod.DialogAuth),
{ ssr: false }
)
export function Chat() {
const { chatId } = useChatSession()
const {
createNewChat,
getChatById,
updateChatModel,
bumpChat,
isLoading: isChatsLoading,
} = useChats()
const currentChat = useMemo(
() => (chatId ? getChatById(chatId) : null),
[chatId, getChatById]
)
const { messages: initialMessages, cacheAndAddMessage } = useMessages()
const { user } = useUser()
const { preferences } = useUserPreferences()
const { draftValue, clearDraft } = useChatDraft(chatId)
// File upload functionality
const {
files,
setFiles,
handleFileUploads,
createOptimisticAttachments,
cleanupOptimisticAttachments,
handleFileUpload,
handleFileRemove,
} = useFileUpload()
// Model selection
const { selectedModel, handleModelChange } = useModel({
currentChat: currentChat || null,
user,
updateChatModel,
chatId,
})
// State to pass between hooks
const [hasDialogAuth, setHasDialogAuth] = useState(false)
const isAuthenticated = useMemo(() => !!user?.id, [user?.id])
const systemPrompt = useMemo(
() => user?.system_prompt || SYSTEM_PROMPT_DEFAULT,
[user?.system_prompt]
)
// New state for quoted text
const [quotedText, setQuotedText] = useState<{
text: string
messageId: string
}>()
const handleQuotedSelected = useCallback(
(text: string, messageId: string) => {
setQuotedText({ text, messageId })
},
[]
)
// Chat operations (utils + handlers) - created first
const { checkLimitsAndNotify, ensureChatExists, handleDelete } =
useChatOperations({
isAuthenticated,
chatId,
messages: initialMessages,
selectedModel,
systemPrompt,
createNewChat,
setHasDialogAuth,
setMessages: () => {},
setInput: () => {},
})
// Core chat functionality (initialization + state + actions)
const {
messages,
input,
status,
stop,
hasSentFirstMessageRef,
isSubmitting,
enableSearch,
setEnableSearch,
submit,
handleSuggestion,
handleReload,
handleInputChange,
submitEdit,
} = useChatCore({
initialMessages,
draftValue,
cacheAndAddMessage,
chatId,
user,
files,
createOptimisticAttachments,
setFiles,
checkLimitsAndNotify,
cleanupOptimisticAttachments,
ensureChatExists,
handleFileUploads,
selectedModel,
clearDraft,
bumpChat,
})
// Memoize the conversation props to prevent unnecessary rerenders
const conversationProps = useMemo(
() => ({
messages,
status,
onDelete: handleDelete,
onEdit: submitEdit,
onReload: handleReload,
onQuote: handleQuotedSelected,
isUserAuthenticated: isAuthenticated,
}),
[
messages,
status,
handleDelete,
submitEdit,
handleReload,
handleQuotedSelected,
isAuthenticated,
]
)
// Memoize the chat input props
const chatInputProps = useMemo(
() => ({
value: input,
onSuggestion: handleSuggestion,
onValueChange: handleInputChange,
onSend: submit,
isSubmitting,
files,
onFileUpload: handleFileUpload,
onFileRemove: handleFileRemove,
hasSuggestions:
preferences.promptSuggestions && !chatId && messages.length === 0,
onSelectModel: handleModelChange,
selectedModel,
isUserAuthenticated: isAuthenticated,
stop,
status,
setEnableSearch,
enableSearch,
quotedText,
}),
[
input,
handleSuggestion,
handleInputChange,
submit,
isSubmitting,
files,
handleFileUpload,
handleFileRemove,
preferences.promptSuggestions,
chatId,
messages.length,
handleModelChange,
selectedModel,
isAuthenticated,
stop,
status,
setEnableSearch,
enableSearch,
quotedText,
]
)
// Handle redirect for invalid chatId - only redirect if we're certain the chat doesn't exist
// and we're not in a transient state during chat creation
if (
chatId &&
!isChatsLoading &&
!currentChat &&
!isSubmitting &&
status === "ready" &&
messages.length === 0 &&
!hasSentFirstMessageRef.current // Don't redirect if we've already sent a message in this session
) {
return redirect("/")
}
const showOnboarding = !chatId && messages.length === 0
return (
<div
className={cn(
"@container/main relative flex h-full flex-col items-center justify-end md:justify-center"
)}
>
<DialogAuth open={hasDialogAuth} setOpen={setHasDialogAuth} />
<AnimatePresence initial={false} mode="popLayout">
{showOnboarding ? (
<motion.div
key="onboarding"
className="absolute bottom-[60%] mx-auto max-w-[50rem] md:relative md:bottom-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
layout="position"
layoutId="onboarding"
transition={{
layout: {
duration: 0,
},
}}
>
<h1 className="mb-6 text-3xl font-medium tracking-tight">
What's on your mind?
</h1>
</motion.div>
) : (
<Conversation key="conversation" {...conversationProps} />
)}
</AnimatePresence>
<motion.div
className={cn(
"relative inset-x-0 bottom-0 z-50 mx-auto w-full max-w-3xl"
)}
layout="position"
layoutId="chat-input-container"
transition={{
layout: {
duration: messages.length === 1 ? 0.3 : 0,
},
}}
>
<ChatInput {...chatInputProps} />
</motion.div>
<FeedbackWidget authUserId={user?.id} />
</div>
)
}
```
## /app/components/chat/conversation.tsx
```tsx path="/app/components/chat/conversation.tsx"
import {
ChatContainerContent,
ChatContainerRoot,
} from "@/components/prompt-kit/chat-container"
import { Loader } from "@/components/prompt-kit/loader"
import { ScrollButton } from "@/components/prompt-kit/scroll-button"
import { ExtendedMessageAISDK } from "@/lib/chat-store/messages/api"
import { Message as MessageType } from "@ai-sdk/react"
import { useRef } from "react"
import { Message } from "./message"
type ConversationProps = {
messages: MessageType[]
status?: "streaming" | "ready" | "submitted" | "error"
onDelete: (id: string) => void
onEdit: (id: string, newText: string) => void
onReload: () => void
onQuote?: (text: string, messageId: string) => void
isUserAuthenticated?: boolean
}
export function Conversation({
messages,
status = "ready",
onDelete,
onEdit,
onReload,
onQuote,
isUserAuthenticated,
}: ConversationProps) {
const initialMessageCount = useRef(messages.length)
if (!messages || messages.length === 0)
return <div className="h-full w-full"></div>
return (
<div className="relative flex h-full w-full flex-col items-center overflow-x-hidden overflow-y-auto">
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10 mx-auto flex w-full flex-col justify-center">
<div className="h-app-header bg-background flex w-full lg:hidden lg:h-0" />
<div className="h-app-header bg-background flex w-full mask-b-from-4% mask-b-to-100% lg:hidden" />
</div>
<ChatContainerRoot className="relative w-full">
<ChatContainerContent
className="flex w-full flex-col items-center pt-20 pb-4"
style={{
scrollbarGutter: "stable both-edges",
scrollbarWidth: "none",
}}
>
{messages?.map((message, index) => {
const isLast =
index === messages.length - 1 && status !== "submitted"
const hasScrollAnchor =
isLast && messages.length > initialMessageCount.current
return (
<Message
key={message.id}
id={message.id}
variant={message.role}
attachments={message.experimental_attachments}
isLast={isLast}
onDelete={onDelete}
onEdit={onEdit}
onReload={onReload}
hasScrollAnchor={hasScrollAnchor}
parts={message.parts}
status={status}
onQuote={onQuote}
messageGroupId={
(message as ExtendedMessageAISDK).message_group_id ?? null
}
isUserAuthenticated={isUserAuthenticated}
>
{message.content}
</Message>
)
})}
{status === "submitted" &&
messages.length > 0 &&
messages[messages.length - 1].role === "user" && (
<div className="group min-h-scroll-anchor flex w-full max-w-3xl flex-col items-start gap-2 px-6 pb-2">
<Loader />
</div>
)}
<div className="absolute bottom-0 flex w-full max-w-3xl flex-1 items-end justify-end gap-4 px-6 pb-2">
<ScrollButton className="absolute top-[-50px] right-[30px]" />
</div>
</ChatContainerContent>
</ChatContainerRoot>
</div>
)
}
```
## /app/components/chat/dialog-auth.tsx
```tsx path="/app/components/chat/dialog-auth.tsx"
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { signInWithGoogle } from "@/lib/api"
import { createClient } from "@/lib/supabase/client"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import Image from "next/image"
import { useState } from "react"
type DialogAuthProps = {
open: boolean
setOpen: (open: boolean) => void
}
export function DialogAuth({ open, setOpen }: DialogAuthProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isSupabaseEnabled) {
return null
}
const supabase = createClient()
if (!supabase) {
return null
}
const handleSignInWithGoogle = async () => {
try {
setIsLoading(true)
setError(null)
const data = await signInWithGoogle(supabase)
// Redirect to the provider URL
if (data?.url) {
window.location.href = data.url
}
} catch (err: unknown) {
console.error("Error signing in with Google:", err)
setError(
(err as Error).message ||
"An unexpected error occurred. Please try again."
)
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl">
You've reached the limit for today
</DialogTitle>
<DialogDescription className="pt-2 text-base">
Sign in below to increase your message limits.
</DialogDescription>
</DialogHeader>
{error && (
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{error}
</div>
)}
<DialogFooter className="mt-6 sm:justify-center">
<Button
variant="secondary"
className="w-full text-base"
size="lg"
onClick={handleSignInWithGoogle}
disabled={isLoading}
>
<img
src="https://www.google.com/favicon.ico"
alt="Google logo"
width={20}
height={20}
className="mr-2 size-4"
/>
<span>{isLoading ? "Connecting..." : "Continue with Google"}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
```
## /app/components/chat/feedback-widget.tsx
```tsx path="/app/components/chat/feedback-widget.tsx"
"use client"
import { useBreakpoint } from "@/app/hooks/use-breakpoint"
import { FeedbackForm } from "@/components/common/feedback-form"
import {
MorphingPopover,
MorphingPopoverContent,
MorphingPopoverTrigger,
} from "@/components/motion-primitives/morphing-popover"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { QuestionMark } from "@phosphor-icons/react"
import { motion } from "motion/react"
import { useState } from "react"
const TRANSITION_POPOVER = {
type: "spring",
bounce: 0.1,
duration: 0.3,
}
type FeedbackWidgetProps = {
authUserId?: string
}
export function FeedbackWidget({ authUserId }: FeedbackWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const isMobileOrTablet = useBreakpoint(896)
if (!isSupabaseEnabled) {
return null
}
const closeMenu = () => {
setIsOpen(false)
}
if (isMobileOrTablet || !authUserId) {
return null
}
return (
<div className="fixed right-1 bottom-1 z-50">
<MorphingPopover
transition={TRANSITION_POPOVER}
open={isOpen}
onOpenChange={setIsOpen}
className="relative flex flex-col items-end justify-end"
>
<MorphingPopoverTrigger
className="border-border bg-background text-foreground hover:bg-secondary flex size-6 items-center justify-center rounded-full border shadow-md"
style={{
transformOrigin: "bottom right",
originX: "right",
originY: "bottom",
scaleX: 1,
scaleY: 1,
}}
>
<span className="sr-only">Help</span>
<motion.span
animate={{
opacity: isOpen ? 0 : 1,
}}
transition={{
duration: 0,
delay: isOpen ? 0 : TRANSITION_POPOVER.duration / 2,
}}
>
<QuestionMark className="text-foreground size-4" />
</motion.span>
</MorphingPopoverTrigger>
<MorphingPopoverContent
className="border-border bg-popover fixed right-1 bottom-1 min-w-[320px] rounded-xl border p-0 shadow-[0_9px_9px_0px_rgba(0,0,0,0.01),_0_2px_5px_0px_rgba(0,0,0,0.06)]"
style={{
transformOrigin: "bottom right",
}}
>
<FeedbackForm authUserId={authUserId} onClose={closeMenu} />
</MorphingPopoverContent>
</MorphingPopover>
</div>
)
}
```
## /app/components/chat/get-sources.ts
```ts path="/app/components/chat/get-sources.ts"
import type { Message as MessageAISDK } from "@ai-sdk/react"
export function getSources(parts: MessageAISDK["parts"]) {
const sources = parts
?.filter(
(part) => part.type === "source" || part.type === "tool-invocation"
)
.map((part) => {
if (part.type === "source") {
return part.source
}
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const result = part.toolInvocation.result
if (
part.toolInvocation.toolName === "summarizeSources" &&
result?.result?.[0]?.citations
) {
return result.result.flatMap((item: { citations?: unknown[] }) => item.citations || [])
}
return Array.isArray(result) ? result.flat() : result
}
return null
})
.filter(Boolean)
.flat()
const validSources =
sources?.filter(
(source) =>
source && typeof source === "object" && source.url && source.url !== ""
) || []
return validSources
}
```
## /app/components/chat/link-markdown.tsx
```tsx path="/app/components/chat/link-markdown.tsx"
export function LinkMarkdown({
href,
children,
...props
}: React.ComponentProps<"a">) {
if (!href) return <span {...props}>{children}</span>
// Check if href is a valid URL
let domain = ""
try {
const url = new URL(href)
domain = url.hostname
} catch {
// If href is not a valid URL (likely a relative path)
domain = href.split("/").pop() || href
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="bg-muted text-muted-foreground hover:bg-muted-foreground/30 hover:text-primary inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 pr-2 pl-0.5 text-xs leading-none overflow-ellipsis whitespace-nowrap no-underline transition-colors duration-150"
>
<img
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(href)}`}
alt="favicon"
width={14}
height={14}
className="size-3.5 rounded-full"
/>
<span className="overflow-hidden font-normal text-ellipsis whitespace-nowrap">
{domain.replace("www.", "")}
</span>
</a>
)
}
```
## /app/components/chat/message-assistant.tsx
```tsx path="/app/components/chat/message-assistant.tsx"
import {
Message,
MessageAction,
MessageActions,
MessageContent,
} from "@/components/prompt-kit/message"
import { useUserPreferences } from "@/lib/user-preference-store/provider"
import { cn } from "@/lib/utils"
import type { Message as MessageAISDK } from "@ai-sdk/react"
import { ArrowClockwise, Check, Copy } from "@phosphor-icons/react"
import { useCallback, useRef } from "react"
import { getSources } from "./get-sources"
import { QuoteButton } from "./quote-button"
import { Reasoning } from "./reasoning"
import { SearchImages } from "./search-images"
import { SourcesList } from "./sources-list"
import { ToolInvocation } from "./tool-invocation"
import { useAssistantMessageSelection } from "./useAssistantMessageSelection"
type MessageAssistantProps = {
children: string
isLast?: boolean
hasScrollAnchor?: boolean
copied?: boolean
copyToClipboard?: () => void
onReload?: () => void
parts?: MessageAISDK["parts"]
status?: "streaming" | "ready" | "submitted" | "error"
className?: string
messageId: string
onQuote?: (text: string, messageId: string) => void
}
export function MessageAssistant({
children,
isLast,
hasScrollAnchor,
copied,
copyToClipboard,
onReload,
parts,
status,
className,
messageId,
onQuote,
}: MessageAssistantProps) {
const { preferences } = useUserPreferences()
const sources = getSources(parts)
const toolInvocationParts = parts?.filter(
(part) => part.type === "tool-invocation"
)
const reasoningParts = parts?.find((part) => part.type === "reasoning")
const contentNullOrEmpty = children === null || children === ""
const isLastStreaming = status === "streaming" && isLast
const searchImageResults =
parts
?.filter(
(part) =>
part.type === "tool-invocation" &&
part.toolInvocation?.state === "result" &&
part.toolInvocation?.toolName === "imageSearch" &&
part.toolInvocation?.result?.content?.[0]?.type === "images"
)
.flatMap((part) =>
part.type === "tool-invocation" &&
part.toolInvocation?.state === "result" &&
part.toolInvocation?.toolName === "imageSearch" &&
part.toolInvocation?.result?.content?.[0]?.type === "images"
? (part.toolInvocation?.result?.content?.[0]?.results ?? [])
: []
) ?? []
const isQuoteEnabled = !preferences.multiModelEnabled
const messageRef = useRef<HTMLDivElement>(null)
const { selectionInfo, clearSelection } = useAssistantMessageSelection(
messageRef,
isQuoteEnabled
)
const handleQuoteBtnClick = useCallback(() => {
if (selectionInfo && onQuote) {
onQuote(selectionInfo.text, selectionInfo.messageId)
clearSelection()
}
}, [selectionInfo, onQuote, clearSelection])
return (
<Message
className={cn(
"group flex w-full max-w-3xl flex-1 items-start gap-4 px-6 pb-2",
hasScrollAnchor && "min-h-scroll-anchor",
className
)}
>
<div
ref={messageRef}
className={cn(
"relative flex min-w-full flex-col gap-2",
isLast && "pb-8"
)}
{...(isQuoteEnabled && { "data-message-id": messageId })}
>
{reasoningParts && reasoningParts.reasoning && (
<Reasoning
reasoning={reasoningParts.reasoning}
isStreaming={status === "streaming"}
/>
)}
{toolInvocationParts &&
toolInvocationParts.length > 0 &&
preferences.showToolInvocations && (
<ToolInvocation toolInvocations={toolInvocationParts} />
)}
{searchImageResults.length > 0 && (
<SearchImages results={searchImageResults} />
)}
{contentNullOrEmpty ? null : (
<MessageContent
className={cn(
"prose dark:prose-invert relative min-w-full bg-transparent p-0",
"prose-h1:scroll-m-20 prose-h1:text-2xl prose-h1:font-semibold prose-h2:mt-8 prose-h2:scroll-m-20 prose-h2:text-xl prose-h2:mb-3 prose-h2:font-medium prose-h3:scroll-m-20 prose-h3:text-base prose-h3:font-medium prose-h4:scroll-m-20 prose-h5:scroll-m-20 prose-h6:scroll-m-20 prose-strong:font-medium prose-table:block prose-table:overflow-y-auto"
)}
markdown={true}
>
{children}
</MessageContent>
)}
{sources && sources.length > 0 && <SourcesList sources={sources} />}
{Boolean(isLastStreaming || contentNullOrEmpty) ? null : (
<MessageActions
className={cn(
"-ml-2 flex gap-0 opacity-0 transition-opacity group-hover:opacity-100"
)}
>
<MessageAction
tooltip={copied ? "Copied!" : "Copy text"}
side="bottom"
>
<button
className="hover:bg-accent/60 text-muted-foreground hover:text-foreground flex size-7.5 items-center justify-center rounded-full bg-transparent transition"
aria-label="Copy text"
onClick={copyToClipboard}
type="button"
>
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</button>
</MessageAction>
{isLast ? (
<MessageAction
tooltip="Regenerate"
side="bottom"
delayDuration={0}
>
<button
className="hover:bg-accent/60 text-muted-foreground hover:text-foreground flex size-7.5 items-center justify-center rounded-full bg-transparent transition"
aria-label="Regenerate"
onClick={onReload}
type="button"
>
<ArrowClockwise className="size-4" />
</button>
</MessageAction>
) : null}
</MessageActions>
)}
{isQuoteEnabled && selectionInfo && selectionInfo.messageId && (
<QuoteButton
mousePosition={selectionInfo.position}
onQuote={handleQuoteBtnClick}
messageContainerRef={messageRef}
onDismiss={clearSelection}
/>
)}
</div>
</Message>
)
}
```
## /app/components/chat/message-user.tsx
```tsx path="/app/components/chat/message-user.tsx"
"use client"
import {
MorphingDialog,
MorphingDialogClose,
MorphingDialogContainer,
MorphingDialogContent,
MorphingDialogImage,
MorphingDialogTrigger,
} from "@/components/motion-primitives/morphing-dialog"
import {
MessageAction,
MessageActions,
Message as MessageContainer,
MessageContent,
} from "@/components/prompt-kit/message"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { isSupabaseEnabled } from "@/lib/supabase/config"
import { cn } from "@/lib/utils"
import { Message as MessageType } from "@ai-sdk/react"
import {
Check,
Copy,
PencilSimpleIcon,
PencilSimpleSlashIcon,
} from "@phosphor-icons/react"
import Image from "next/image"
import React, { useEffect, useRef, useState } from "react"
const getTextFromDataUrl = (dataUrl: string) => {
const base64 = dataUrl.split(",")[1]
return base64
}
export type MessageUserProps = {
hasScrollAnchor?: boolean
attachments?: MessageType["experimental_attachments"]
children: string
copied: boolean
copyToClipboard: () => void
id: string
className?: string
onReload?: () => void
onEdit?: (id: string, newText: string) => void
messageGroupId?: string | null
isUserAuthenticated?: boolean
}
export function MessageUser({
hasScrollAnchor,
attachments,
children,
copied,
copyToClipboard,
id,
className,
onEdit,
messageGroupId,
isUserAuthenticated,
}: MessageUserProps) {
const [editInput, setEditInput] = useState(children)
const [isEditing, setIsEditing] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleEditCancel = () => {
setIsEditing(false)
setEditInput(children)
}
const handleSave = async () => {
if (!editInput.trim()) return
const UUIDLength = 36
try {
if (isSupabaseEnabled && id && id.length !== UUIDLength) {
// Message IDs failed to sync
toast({
title: "Oops, something went wrong",
description: "Please refresh your browser and try again.",
status: "error",
})
return
}
onEdit?.(id, editInput)
} catch {
setEditInput(children) // Reset on failure
} finally {
setIsEditing(false)
}
}
const handleEditStart = async () => {
setIsEditing(true)
setEditInput(children)
}
useEffect(() => {
if (!isEditing) return
const editTextarea = textareaRef.current
if (!editTextarea) return
editTextarea.style.height = "auto"
editTextarea.style.height = `${Math.min(editTextarea.scrollHeight, editTextarea.scrollHeight)}px`
}, [editInput, isEditing])
return (
<MessageContainer
className={cn(
"group flex w-full max-w-3xl flex-col items-end gap-0.5 px-6 pb-2",
hasScrollAnchor && "min-h-scroll-anchor",
className
)}
>
{attachments?.map((attachment, index) => (
<div
className="flex flex-row gap-2"
key={`${attachment.name}-${index}`}
>
{attachment.contentType?.startsWith("image") ? (
<MorphingDialog
transition={{
type: "spring",
stiffness: 280,
damping: 18,
mass: 0.3,
}}
>
<MorphingDialogTrigger className="z-10">
<Image
className="mb-1 w-40 rounded-md"
key={attachment.name}
src={attachment.url}
alt={attachment.name || "Attachment"}
width={160}
height={120}
/>
</MorphingDialogTrigger>
<MorphingDialogContainer>
<MorphingDialogContent className="relative rounded-lg">
<MorphingDialogImage
src={attachment.url}
alt={attachment.name || ""}
className="max-h-[90vh] max-w-[90vw] object-contain"
/>
</MorphingDialogContent>
<MorphingDialogClose className="text-primary" />
</MorphingDialogContainer>
</MorphingDialog>
) : attachment.contentType?.startsWith("text") ? (
<div className="text-primary mb-3 h-24 w-40 overflow-hidden rounded-md border p-2 text-xs">
{getTextFromDataUrl(attachment.url)}
</div>
) : null}
</div>
))}
{isEditing ? (
<div
className="bg-accent relative flex w-full max-w-xl min-w-[180px] flex-col gap-2 rounded-3xl px-5 py-2.5"
style={{
width: contentRef.current?.offsetWidth,
}}
>
<textarea
ref={textareaRef}
className="w-full resize-none bg-transparent outline-none"
value={editInput}
onChange={(e) => setEditInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSave()
}
if (e.key === "Escape") {
handleEditCancel()
}
}}
autoFocus
style={{
maxHeight: "50vh",
overflowY: "auto",
}}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!editInput.trim()}>
Save
</Button>
</div>
</div>
) : (
<MessageContent
className="bg-accent prose dark:prose-invert relative max-w-[70%] rounded-3xl px-5 py-2.5"
markdown={true}
ref={contentRef}
components={{
code: ({ children }) => <React.Fragment>{children}</React.Fragment>,
pre: ({ children }) => <React.Fragment>{children}</React.Fragment>,
h1: ({ children }) => <p>{children}</p>,
h2: ({ children }) => <p>{children}</p>,
h3: ({ children }) => <p>{children}</p>,
h4: ({ children }) => <p>{children}</p>,
h5: ({ children }) => <p>{children}</p>,
h6: ({ children }) => <p>{children}</p>,
p: ({ children }) => <p>{children}</p>,
li: ({ children }) => <p>- {children}</p>,
ul: ({ children }) => <React.Fragment>{children}</React.Fragment>,
ol: ({ children }) => <React.Fragment>{children}</React.Fragment>,
}}
>
{children}
</MessageContent>
)}
<MessageActions className="flex gap-0 opacity-0 transition-opacity duration-0 group-hover:opacity-100">
<MessageAction tooltip={copied ? "Copied!" : "Copy text"} side="bottom">
<button
className="hover:bg-accent/60 text-muted-foreground hover:text-foreground flex size-7.5 items-center justify-center rounded-full bg-transparent transition"
aria-label="Copy text"
onClick={copyToClipboard}
type="button"
>
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</button>
</MessageAction>
{messageGroupId === null && isUserAuthenticated && (
// Enabled if NOT multi-model chat & user is Authenticated
<MessageAction
tooltip={isEditing ? "Cancel edit" : "Edit message"}
side="bottom"
delayDuration={0}
>
<button
className="hover:bg-accent/60 text-muted-foreground hover:text-foreground flex size-7.5 items-center justify-center rounded-full bg-transparent transition"
aria-label={isEditing ? "Cancel edit" : "Edit message"}
onClick={isEditing ? handleEditCancel : handleEditStart}
type="button"
>
{isEditing ? (
<PencilSimpleSlashIcon className="size-4" />
) : (
<PencilSimpleIcon className="size-4" />
)}
</button>
</MessageAction>
)}
</MessageActions>
</MessageContainer>
)
}
```
## /app/components/chat/message.tsx
```tsx path="/app/components/chat/message.tsx"
import { Message as MessageType } from "@ai-sdk/react"
import React, { useState } from "react"
import { MessageAssistant } from "./message-assistant"
import { MessageUser } from "./message-user"
type MessageProps = {
variant: MessageType["role"]
children: string
id: string
attachments?: MessageType["experimental_attachments"]
isLast?: boolean
onDelete: (id: string) => void
onEdit: (id: string, newText: string) => Promise<void> | void
onReload: () => void
hasScrollAnchor?: boolean
parts?: MessageType["parts"]
status?: "streaming" | "ready" | "submitted" | "error"
className?: string
onQuote?: (text: string, messageId: string) => void
messageGroupId?: string | null
isUserAuthenticated?: boolean
}
export function Message({
variant,
children,
id,
attachments,
isLast,
onEdit,
onReload,
hasScrollAnchor,
parts,
status,
className,
onQuote,
messageGroupId,
isUserAuthenticated,
}: MessageProps) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(children)
setCopied(true)
setTimeout(() => setCopied(false), 500)
}
if (variant === "user") {
return (
<MessageUser
copied={copied}
copyToClipboard={copyToClipboard}
onReload={onReload}
onEdit={onEdit}
id={id}
hasScrollAnchor={hasScrollAnchor}
attachments={attachments}
className={className}
messageGroupId={messageGroupId}
isUserAuthenticated={isUserAuthenticated}
>
{children}
</MessageUser>
)
}
if (variant === "assistant") {
return (
<MessageAssistant
copied={copied}
copyToClipboard={copyToClipboard}
onReload={onReload}
isLast={isLast}
hasScrollAnchor={hasScrollAnchor}
parts={parts}
status={status}
className={className}
messageId={id}
onQuote={onQuote}
>
{children}
</MessageAssistant>
)
}
return null
}
```
## /app/components/chat/quote-button.tsx
```tsx path="/app/components/chat/quote-button.tsx"
import useClickOutside from "@/components/motion-primitives/useClickOutside"
import { Button } from "@/components/ui/button"
import { Quote } from "lucide-react"
import { RefObject, useRef } from "react"
type QuoteButtonProps = {
mousePosition: { x: number; y: number }
onQuote: () => void
messageContainerRef: RefObject<HTMLElement | null>
onDismiss: () => void
}
export function QuoteButton({
mousePosition,
onQuote,
messageContainerRef,
onDismiss,
}: QuoteButtonProps) {
const buttonRef = useRef<HTMLDivElement>(null)
useClickOutside(buttonRef as RefObject<HTMLElement>, onDismiss)
const buttonHeight = 60
const containerRect = messageContainerRef.current?.getBoundingClientRect()
const position = containerRect
? {
top: mousePosition.y - containerRect.top - buttonHeight,
left: mousePosition.x - containerRect.left,
}
: { top: 0, left: 0 }
return (
<div
ref={buttonRef}
className="absolute z-50 flex gap-2 rounded-full"
style={{
top: position.top,
left: position.left,
transform: "translateX(-50%)",
}}
>
<Button
onClick={onQuote}
className="flex size-10 items-center gap-1 rounded-full px-3 py-1 text-base"
aria-label="Ask follow up"
>
<Quote className="size-4" />
</Button>
</div>
)
}
```
## /app/components/chat/reasoning.tsx
```tsx path="/app/components/chat/reasoning.tsx"
import { Markdown } from "@/components/prompt-kit/markdown"
import { cn } from "@/lib/utils"
import { CaretDownIcon } from "@phosphor-icons/react"
import { AnimatePresence, motion } from "framer-motion"
import { useState } from "react"
type ReasoningProps = {
reasoning: string
isStreaming?: boolean
}
const TRANSITION = {
type: "spring",
duration: 0.2,
bounce: 0,
}
export function Reasoning({ reasoning, isStreaming }: ReasoningProps) {
const [wasStreaming, setWasStreaming] = useState(isStreaming ?? false)
const [isExpanded, setIsExpanded] = useState(() => isStreaming ?? true)
if (wasStreaming && isStreaming === false) {
setWasStreaming(false)
setIsExpanded(false)
}
return (
<div>
<button
className="text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
>
<span>Reasoning</span>
<CaretDownIcon
className={cn(
"size-3 transition-transform",
isExpanded ? "rotate-180" : ""
)}
/>
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
className="mt-2 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={TRANSITION}
>
<div className="text-muted-foreground border-muted-foreground/20 flex flex-col border-l pl-4 text-sm">
<Markdown>{reasoning}</Markdown>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
```
## /app/components/chat/search-images.tsx
```tsx path="/app/components/chat/search-images.tsx"
import Image from "next/image"
import { useState } from "react"
import { addUTM, getFavicon, getSiteName } from "./utils"
type ImageResult = {
title: string
imageUrl: string
sourceUrl: string
}
export function SearchImages({ results }: { results: ImageResult[] }) {
const [hiddenIndexes, setHiddenIndexes] = useState<Set<number>>(new Set())
const handleError = (index: number) => {
setHiddenIndexes((prev) => new Set(prev).add(index))
}
if (!results?.length) return null
return (
<div className="my-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
{results.map((img, i) => {
const favicon = getFavicon(img.sourceUrl)
return hiddenIndexes.has(i) ? null : (
<a
key={i}
href={addUTM(img.sourceUrl)}
target="_blank"
rel="noopener noreferrer"
className="group/image relative block overflow-hidden rounded-xl"
>
<Image
src={img.imageUrl}
alt={img.title}
onError={() => handleError(i)}
onLoad={(e) => e.currentTarget.classList.remove("opacity-0")}
className="h-full max-h-48 min-h-40 w-full object-cover opacity-0 transition-opacity duration-150 ease-out"
/>
<div className="bg-primary absolute right-0 bottom-0 left-0 flex flex-col gap-0.5 px-2.5 py-1.5 opacity-0 transition-opacity duration-100 ease-out group-hover/image:opacity-100">
<div className="flex items-center gap-1">
{favicon && (
<Image
src={favicon}
alt="favicon"
className="h-4 w-4 rounded-full"
/>
)}
<span className="text-secondary line-clamp-1 text-xs">
{getSiteName(img.sourceUrl)}
</span>
</div>
<span className="text-secondary line-clamp-1 text-xs">
{img.title}
</span>
</div>
</a>
)
})}
</div>
)
}
```
## /app/components/chat/sources-list.tsx
```tsx path="/app/components/chat/sources-list.tsx"
"use client"
import { cn } from "@/lib/utils"
import type { SourceUIPart } from "@ai-sdk/ui-utils"
import { CaretDown, Link } from "@phosphor-icons/react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import { useState } from "react"
import { addUTM, formatUrl, getFavicon } from "./utils"
type SourcesListProps = {
sources: SourceUIPart["source"][]
className?: string
}
const TRANSITION = {
type: "spring",
duration: 0.2,
bounce: 0,
}
export function SourcesList({ sources, className }: SourcesListProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [failedFavicons, setFailedFavicons] = useState<Set<string>>(new Set())
const handleFaviconError = (url: string) => {
setFailedFavicons((prev) => new Set(prev).add(url))
}
return (
<div className={cn("my-4", className)}>
<div className="border-border flex flex-col gap-0 overflow-hidden rounded-md border">
<button
onClick={() => setIsExpanded(!isExpanded)}
type="button"
className="hover:bg-accent flex w-full flex-row items-center rounded-t-md px-3 py-2 transition-colors"
>
<div className="flex flex-1 flex-row items-center gap-2 text-left text-sm">
Sources
<div className="flex -space-x-1">
{sources?.map((source, index) => {
const faviconUrl = getFavicon(source.url)
const showFallback =
!faviconUrl || failedFavicons.has(source.url)
return showFallback ? (
<div
key={`${source.url}-${index}`}
className="bg-muted border-background h-4 w-4 rounded-full border"
/>
) : (
<Image
key={`${source.url}-${index}`}
src={faviconUrl}
alt={`Favicon for ${source.title}`}
width={16}
height={16}
className="border-background h-4 w-4 rounded-sm border"
onError={() => handleFaviconError(source.url)}
/>
)
})}
{sources.length > 3 && (
<span className="text-muted-foreground ml-1 text-xs">
+{sources.length - 3}
</span>
)}
</div>
</div>
<CaretDown
className={cn(
"h-4 w-4 transition-transform",
isExpanded ? "rotate-180 transform" : ""
)}
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={TRANSITION}
className="overflow-hidden"
>
<ul className="space-y-2 px-3 pt-3 pb-3">
{sources.map((source) => {
const faviconUrl = getFavicon(source.url)
const showFallback =
!faviconUrl || failedFavicons.has(source.url)
return (
<li key={source.id} className="flex items-center text-sm">
<div className="min-w-0 flex-1 overflow-hidden">
<a
href={addUTM(source.url)}
target="_blank"
rel="noopener noreferrer"
className="text-primary group line-clamp-1 flex items-center gap-1 hover:underline"
>
{showFallback ? (
<div className="bg-muted h-4 w-4 flex-shrink-0 rounded-full" />
) : (
<Image
src={faviconUrl}
alt={`Favicon for ${source.title}`}
width={16}
height={16}
className="h-4 w-4 flex-shrink-0 rounded-sm"
onError={() => handleFaviconError(source.url)}
/>
)}
<span className="truncate">{source.title}</span>
<Link className="inline h-3 w-3 flex-shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
</a>
<div className="text-muted-foreground line-clamp-1 text-xs">
{formatUrl(source.url)}
</div>
</div>
</li>
)
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
```
## /app/components/chat/syncRecentMessages.ts
```ts path="/app/components/chat/syncRecentMessages.ts"
import { getLastMessagesFromDb } from "@/lib/chat-store/messages/api"
import { writeToIndexedDB } from "@/lib/chat-store/persist"
import type { Message as MessageAI } from "ai"
export async function syncRecentMessages(
chatId: string,
setMessages: (updater: (prev: MessageAI[]) => MessageAI[]) => void,
count: number = 2
): Promise<void> {
const lastFromDb = await getLastMessagesFromDb(chatId, count)
if (!lastFromDb || lastFromDb.length === 0) return
setMessages((prev) => {
if (!prev || prev.length === 0) return prev
const updated = [...prev]
let changed = false
// Pair from the end; for each DB message (last to first),
for (let d = lastFromDb.length - 1; d >= 0; d--) {
const dbMsg = lastFromDb[d]
const dbRole = dbMsg.role
for (let i = updated.length - 1; i >= 0; i--) {
const local = updated[i]
if (local.role !== dbRole) continue
if (String(local.id) !== String(dbMsg.id)) {
updated[i] = {
...local,
id: String(dbMsg.id),
createdAt: dbMsg.createdAt,
}
changed = true
}
break
}
}
if (changed) {
writeToIndexedDB("messages", { id: chatId, messages: updated })
return updated
}
return prev
})
}
```
## /app/components/chat/tool-invocation.tsx
```tsx path="/app/components/chat/tool-invocation.tsx"
"use client"
import { cn } from "@/lib/utils"
import type { ToolInvocationUIPart } from "@ai-sdk/ui-utils"
import {
CaretDown,
CheckCircle,
Code,
Link,
Nut,
Spinner,
Wrench,
} from "@phosphor-icons/react"
import { AnimatePresence, motion } from "framer-motion"
import { useMemo, useState } from "react"
interface ToolInvocationProps {
toolInvocations: ToolInvocationUIPart[]
className?: string
defaultOpen?: boolean
}
const TRANSITION = {
type: "spring",
duration: 0.2,
bounce: 0,
}
export function ToolInvocation({
toolInvocations,
defaultOpen = false,
}: ToolInvocationProps) {
const [isExpanded, setIsExpanded] = useState(defaultOpen)
const toolInvocationsData = Array.isArray(toolInvocations)
? toolInvocations
: [toolInvocations]
// Group tool invocations by toolCallId
const groupedTools = toolInvocationsData.reduce(
(acc, item) => {
const { toolCallId } = item.toolInvocation
if (!acc[toolCallId]) {
acc[toolCallId] = []
}
acc[toolCallId].push(item)
return acc
},
{} as Record<string, ToolInvocationUIPart[]>
)
const uniqueToolIds = Object.keys(groupedTools)
const isSingleTool = uniqueToolIds.length === 1
if (isSingleTool) {
return (
<SingleToolView
toolInvocations={toolInvocationsData}
defaultOpen={defaultOpen}
className="mb-10"
/>
)
}
return (
<div className="mb-10">
<div className="border-border flex flex-col gap-0 overflow-hidden rounded-md border">
<button
onClick={(e) => {
e.preventDefault()
setIsExpanded(!isExpanded)
}}
type="button"
className="hover:bg-accent flex w-full flex-row items-center rounded-t-md px-3 py-2 transition-colors"
>
<div className="flex flex-1 flex-row items-center gap-2 text-left text-base">
<Nut className="text-muted-foreground size-4" />
<span className="text-sm">Tools executed</span>
<div className="bg-secondary text-secondary-foreground rounded-full px-1.5 py-0.5 font-mono text-xs">
{uniqueToolIds.length}
</div>
</div>
<CaretDown
className={cn(
"h-4 w-4 transition-transform",
isExpanded ? "rotate-180 transform" : ""
)}
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={TRANSITION}
className="overflow-hidden"
>
<div className="px-3 pt-3 pb-3">
<div className="space-y-2">
{uniqueToolIds.map((toolId) => {
const toolInvocationsForId = groupedTools[toolId]
if (!toolInvocationsForId?.length) return null
return (
<div
key={toolId}
className="pb-2 last:border-0 last:pb-0"
>
<SingleToolView
toolInvocations={toolInvocationsForId}
/>
</div>
)
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
type SingleToolViewProps = {
toolInvocations: ToolInvocationUIPart[]
defaultOpen?: boolean
className?: string
}
function SingleToolView({
toolInvocations,
defaultOpen = false,
className,
}: SingleToolViewProps) {
// Group by toolCallId and pick the most informative state
const groupedTools = toolInvocations.reduce(
(acc, item) => {
const { toolCallId } = item.toolInvocation
if (!acc[toolCallId]) {
acc[toolCallId] = []
}
acc[toolCallId].push(item)
return acc
},
{} as Record<string, ToolInvocationUIPart[]>
)
// For each toolCallId, get the most informative state (result > call > requested)
const toolsToDisplay = Object.values(groupedTools)
.map((group) => {
const resultTool = group.find(
(item) => item.toolInvocation.state === "result"
)
const callTool = group.find(
(item) => item.toolInvocation.state === "call"
)
const partialCallTool = group.find(
(item) => item.toolInvocation.state === "partial-call"
)
// Return the most informative one
return resultTool || callTool || partialCallTool
})
.filter(Boolean) as ToolInvocationUIPart[]
if (toolsToDisplay.length === 0) return null
// If there's only one tool, display it directly
if (toolsToDisplay.length === 1) {
return (
<SingleToolCard
toolData={toolsToDisplay[0]}
defaultOpen={defaultOpen}
className={className}
/>
)
}
// If there are multiple tools, show them in a list
return (
<div className={className}>
<div className="space-y-4">
{toolsToDisplay.map((tool) => (
<SingleToolCard
key={tool.toolInvocation.toolCallId}
toolData={tool}
defaultOpen={defaultOpen}
/>
))}
</div>
</div>
)
}
// New component to handle individual tool cards
function SingleToolCard({
toolData,
defaultOpen = false,
className,
}: {
toolData: ToolInvocationUIPart
defaultOpen?: boolean
className?: string
}) {
const [isExpanded, setIsExpanded] = useState(defaultOpen)
const { toolInvocation } = toolData
const { state, toolName, toolCallId, args } = toolInvocation
const isLoading = state === "call"
const isCompleted = state === "result"
const result = isCompleted ? toolInvocation.result : undefined
// Parse the result JSON if available
const { parsedResult, parseError } = useMemo(() => {
if (!isCompleted || !result) return { parsedResult: null, parseError: null }
try {
if (Array.isArray(result))
return { parsedResult: result, parseError: null }
if (
typeof result === "object" &&
result !== null &&
"content" in result
) {
const textContent = result.content?.find(
(item: { type: string }) => item.type === "text"
)
if (!textContent?.text) return { parsedResult: null, parseError: null }
try {
return {
parsedResult: JSON.parse(textContent.text),
parseError: null,
}
} catch {
return { parsedResult: textContent.text, parseError: null }
}
}
return { parsedResult: result, parseError: null }
} catch {
return { parsedResult: null, parseError: "Failed to parse result" }
}
}, [isCompleted, result])
// Format the arguments for display
const formattedArgs = args
? Object.entries(args).map(([key, value]) => (
<div key={key} className="mb-1">
<span className="text-muted-foreground font-medium">{key}:</span>{" "}
<span className="font-mono">
{typeof value === "object"
? value === null
? "null"
: Array.isArray(value)
? value.length === 0
? "[]"
: JSON.stringify(value)
: JSON.stringify(value)
: String(value)}
</span>
</div>
))
: null
// Render generic results based on their structure
const renderResults = () => {
if (!parsedResult) return "No result data available"
// Handle array of items with url, title, and snippet (like search results)
if (Array.isArray(parsedResult) && parsedResult.length > 0) {
// Check if items look like search results
if (
parsedResult[0] &&
typeof parsedResult[0] === "object" &&
"url" in parsedResult[0] &&
"title" in parsedResult[0]
) {
return (
<div className="space-y-3">
{parsedResult.map(
(
item: { url: string; title: string; snippet?: string },
index: number
) => (
<div
key={index}
className="border-border border-b pb-3 last:border-0 last:pb-0"
>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary group flex items-center gap-1 font-medium hover:underline"
>
{item.title}
<Link className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" />
</a>
<div className="text-muted-foreground mt-1 font-mono text-xs">
{item.url}
</div>
{item.snippet && (
<div className="mt-1 line-clamp-2 text-sm">
{item.snippet}
</div>
)}
</div>
)
)}
</div>
)
}
// Generic array display
return (
<div className="font-mono text-xs">
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedResult, null, 2)}
</pre>
</div>
)
}
// Handle object results
if (typeof parsedResult === "object" && parsedResult !== null) {
const resultObj = parsedResult as Record<string, unknown>
const title = typeof resultObj.title === "string" ? resultObj.title : null
const htmlUrl =
typeof resultObj.html_url === "string" ? resultObj.html_url : null
return (
<div>
{title && <div className="mb-2 font-medium">{title}</div>}
{htmlUrl && (
<div className="mb-2">
<a
href={htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary flex items-center gap-1 hover:underline"
>
<span className="font-mono">{htmlUrl}</span>
<Link className="h-3 w-3 opacity-70" />
</a>
</div>
)}
<div className="font-mono text-xs">
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedResult, null, 2)}
</pre>
</div>
</div>
)
}
// Handle string results
if (typeof parsedResult === "string") {
return <div className="whitespace-pre-wrap">{parsedResult}</div>
}
// Fallback
return "No result data available"
}
return (
<div
className={cn(
"border-border flex flex-col gap-0 overflow-hidden rounded-md border",
className
)}
>
<button
onClick={(e) => {
e.preventDefault()
setIsExpanded(!isExpanded)
}}
type="button"
className="hover:bg-accent flex w-full flex-row items-center rounded-t-md px-3 py-2 transition-colors"
>
<div className="flex flex-1 flex-row items-center gap-2 text-left text-base">
<Wrench className="text-muted-foreground size-4" />
<span className="font-mono text-sm">{toolName}</span>
<AnimatePresence mode="popLayout" initial={false}>
{isLoading ? (
<motion.div
initial={{ opacity: 0, scale: 0.9, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.9, filter: "blur(2px)" }}
transition={{ duration: 0.15 }}
key="loading"
>
<div className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-400">
<Spinner className="mr-1 h-3 w-3 animate-spin" />
Running
</div>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.9, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.9, filter: "blur(2px)" }}
transition={{ duration: 0.15 }}
key="completed"
>
<div className="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-1.5 py-0.5 text-xs text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<CaretDown
className={cn(
"h-4 w-4 transition-transform",
isExpanded ? "rotate-180 transform" : ""
)}
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={TRANSITION}
className="overflow-hidden"
>
<div className="space-y-3 px-3 pt-3 pb-3">
{/* Arguments section */}
{args && Object.keys(args).length > 0 && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">
Arguments
</div>
<div className="bg-background rounded border p-2 text-sm">
{formattedArgs}
</div>
</div>
)}
{/* Result section */}
{isCompleted && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">
Result
</div>
<div className="bg-background max-h-60 overflow-auto rounded border p-2 text-sm">
{parseError ? (
<div className="text-red-500">{parseError}</div>
) : (
renderResults()
)}
</div>
</div>
)}
{/* Tool call ID */}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center">
<Code className="mr-1 inline size-3" />
Tool Call ID:{" "}
<span className="ml-1 font-mono">{toolCallId}</span>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
```
## /app/components/chat/use-chat-core.ts
```ts path="/app/components/chat/use-chat-core.ts"
import { syncRecentMessages } from "@/app/components/chat/syncRecentMessages"
import { useChatDraft } from "@/app/hooks/use-chat-draft"
import { toast } from "@/components/ui/toast"
import { getOrCreateGuestUserId } from "@/lib/api"
import { useChats } from "@/lib/chat-store/chats/provider"
import { MESSAGE_MAX_LENGTH, SYSTEM_PROMPT_DEFAULT } from "@/lib/config"
import { Attachment } from "@/lib/file-handling"
import { API_ROUTE_CHAT } from "@/lib/routes"
import type { UserProfile } from "@/lib/user/types"
import type { Message } from "@ai-sdk/react"
import { useChat } from "@ai-sdk/react"
import { useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
type UseChatCoreProps = {
initialMessages: Message[]
draftValue: string
cacheAndAddMessage: (message: Message) => void
chatId: string | null
user: UserProfile | null
files: File[]
createOptimisticAttachments: (
files: File[]
) => Array<{ name: string; contentType: string; url: string }>
setFiles: (files: File[]) => void
checkLimitsAndNotify: (uid: string) => Promise<boolean>
cleanupOptimisticAttachments: (attachments?: Array<{ url?: string }>) => void
ensureChatExists: (uid: string, input: string) => Promise<string | null>
handleFileUploads: (
uid: string,
chatId: string
) => Promise<Attachment[] | null>
selectedModel: string
clearDraft: () => void
bumpChat: (chatId: string) => void
}
export function useChatCore({
initialMessages,
draftValue,
cacheAndAddMessage,
chatId,
user,
files,
createOptimisticAttachments,
setFiles,
checkLimitsAndNotify,
cleanupOptimisticAttachments,
ensureChatExists,
handleFileUploads,
selectedModel,
clearDraft,
bumpChat,
}: UseChatCoreProps) {
// State management
const [isSubmitting, setIsSubmitting] = useState(false)
const [hasDialogAuth, setHasDialogAuth] = useState(false)
const [enableSearch, setEnableSearch] = useState(false)
// Refs and derived state
const hasSentFirstMessageRef = useRef(false)
const prevChatIdRef = useRef<string | null>(chatId)
const isAuthenticated = useMemo(() => !!user?.id, [user?.id])
const systemPrompt = useMemo(
() => user?.system_prompt || SYSTEM_PROMPT_DEFAULT,
[user?.system_prompt]
)
// Search params handling
const searchParams = useSearchParams()
const prompt = searchParams.get("prompt")
// Chats operations
const { updateTitle } = useChats()
// Handle errors directly in onError callback
const handleError = useCallback((error: Error) => {
console.error("Chat error:", error)
console.error("Error message:", error.message)
let errorMsg = error.message || "Something went wrong."
if (errorMsg === "An error occurred" || errorMsg === "fetch failed") {
errorMsg = "Something went wrong. Please try again."
}
toast({
title: errorMsg,
status: "error",
})
}, [])
// Initialize useChat
const {
messages,
input,
handleSubmit,
status,
error,
reload,
stop,
setMessages,
setInput,
append,
} = useChat({
api: API_ROUTE_CHAT,
initialMessages,
initialInput: draftValue,
onFinish: async (m) => {
cacheAndAddMessage(m)
try {
const effectiveChatId =
chatId ||
prevChatIdRef.current ||
(typeof window !== "undefined"
? localStorage.getItem("guestChatId")
: null)
if (!effectiveChatId) return
await syncRecentMessages(effectiveChatId, setMessages, 2)
} catch (error) {
console.error("Message ID reconciliation failed: ", error)
}
},
onError: handleError,
})
// Handle search params on mount
useEffect(() => {
if (prompt && typeof window !== "undefined") {
requestAnimationFrame(() => setInput(prompt))
}
}, [prompt, setInput])
// Reset messages when navigating from a chat to home
if (
prevChatIdRef.current !== null &&
chatId === null &&
messages.length > 0
) {
setMessages([])
}
prevChatIdRef.current = chatId
// Submit action
const submit = useCallback(async () => {
setIsSubmitting(true)
const uid = await getOrCreateGuestUserId(user)
if (!uid) {
setIsSubmitting(false)
return
}
const optimisticId = `optimistic-${Date.now().toString()}`
const optimisticAttachments =
files.length > 0 ? createOptimisticAttachments(files) : []
const optimisticMessage = {
id: optimisticId,
content: input,
role: "user" as const,
createdAt: new Date(),
experimental_attachments:
optimisticAttachments.length > 0 ? optimisticAttachments : undefined,
}
setMessages((prev) => [...prev, optimisticMessage])
setInput("")
const submittedFiles = [...files]
setFiles([])
try {
const allowed = await checkLimitsAndNotify(uid)
if (!allowed) {
setMessages((prev) => prev.filter((m) => m.id !== optimisticId))
cleanupOptimisticAttachments(optimisticMessage.experimental_attachments)
return
}
const currentChatId = await ensureChatExists(uid, input)
if (!currentChatId) {
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
cleanupOptimisticAttachments(optimisticMessage.experimental_attachments)
return
}
prevChatIdRef.current = currentChatId
if (input.length > MESSAGE_MAX_LENGTH) {
toast({
title: `The message you submitted was too long, please submit something shorter. (Max ${MESSAGE_MAX_LENGTH} characters)`,
status: "error",
})
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
cleanupOptimisticAttachments(optimisticMessage.experimental_attachments)
return
}
let attachments: Attachment[] | null = []
if (submittedFiles.length > 0) {
attachments = await handleFileUploads(uid, currentChatId)
if (attachments === null) {
setMessages((prev) => prev.filter((m) => m.id !== optimisticId))
cleanupOptimisticAttachments(
optimisticMessage.experimental_attachments
)
return
}
}
const options = {
body: {
chatId: currentChatId,
userId: uid,
model: selectedModel,
isAuthenticated,
systemPrompt: systemPrompt || SYSTEM_PROMPT_DEFAULT,
enableSearch,
},
experimental_attachments: attachments || undefined,
}
handleSubmit(undefined, options)
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
cleanupOptimisticAttachments(optimisticMessage.experimental_attachments)
cacheAndAddMessage(optimisticMessage)
clearDraft()
if (messages.length > 0) {
bumpChat(currentChatId)
}
} catch {
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
cleanupOptimisticAttachments(optimisticMessage.experimental_attachments)
toast({ title: "Failed to send message", status: "error" })
} finally {
setIsSubmitting(false)
}
}, [
user,
files,
createOptimisticAttachments,
input,
setMessages,
setInput,
setFiles,
checkLimitsAndNotify,
cleanupOptimisticAttachments,
ensureChatExists,
handleFileUploads,
selectedModel,
isAuthenticated,
systemPrompt,
enableSearch,
handleSubmit,
cacheAndAddMessage,
clearDraft,
messages.length,
bumpChat,
setIsSubmitting,
])
const submitEdit = useCallback(
async (messageId: string, newContent: string) => {
// Block edits while sending/streaming
if (isSubmitting || status === "submitted" || status === "streaming") {
toast({
title: "Please wait until the current message finishes sending.",
status: "error",
})
return
}
if (!newContent.trim()) return
if (!chatId) {
toast({ title: "Missing chat.", status: "error" })
return
}
// Find edited message
const editIndex = messages.findIndex(
(m) => String(m.id) === String(messageId)
)
if (editIndex === -1) {
toast({ title: "Message not found", status: "error" })
return
}
const target = messages[editIndex]
const cutoffIso = target?.createdAt?.toISOString()
if (!cutoffIso) {
console.error("Unable to locate message timestamp.")
return
}
if (newContent.length > MESSAGE_MAX_LENGTH) {
toast({
title: `The message you submitted was too long, please submit something shorter. (Max ${MESSAGE_MAX_LENGTH} characters)`,
status: "error",
})
return
}
// Store original messages for potential rollback
const originalMessages = [...messages]
const optimisticId = `optimistic-edit-${Date.now().toString()}`
const optimisticEditedMessage = {
id: optimisticId,
content: newContent,
role: "user" as const,
createdAt: new Date(),
experimental_attachments: target.experimental_attachments || undefined,
}
try {
const trimmedMessages = messages.slice(0, editIndex)
setMessages([...trimmedMessages, optimisticEditedMessage])
try {
const { writeToIndexedDB } = await import("@/lib/chat-store/persist")
await writeToIndexedDB("messages", {
id: chatId,
messages: trimmedMessages,
})
} catch {}
// Get user validation
const uid = await getOrCreateGuestUserId(user)
if (!uid) {
setMessages(originalMessages)
toast({ title: "Please sign in and try again.", status: "error" })
return
}
const allowed = await checkLimitsAndNotify(uid)
if (!allowed) {
setMessages(originalMessages)
return
}
const currentChatId = await ensureChatExists(uid, newContent)
if (!currentChatId) {
setMessages(originalMessages)
return
}
prevChatIdRef.current = currentChatId
const options = {
body: {
chatId: currentChatId,
userId: uid,
model: selectedModel,
isAuthenticated,
systemPrompt: systemPrompt || SYSTEM_PROMPT_DEFAULT,
enableSearch,
editCutoffTimestamp: cutoffIso, // Backend will delete messages from this timestamp
},
experimental_attachments:
target.experimental_attachments || undefined,
}
// If this is an edit of the very first user message, update chat title
if (editIndex === 0 && target.role === "user") {
try {
await updateTitle(currentChatId, newContent)
} catch {}
}
append(
{
role: "user",
content: newContent,
},
options
)
// Remove optimistic message
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
bumpChat(currentChatId)
} catch (error) {
console.error("Edit failed:", error)
setMessages(originalMessages)
toast({ title: "Failed to apply edit", status: "error" })
}
},
[
chatId,
messages,
user,
checkLimitsAndNotify,
ensureChatExists,
selectedModel,
isAuthenticated,
systemPrompt,
enableSearch,
append,
setMessages,
bumpChat,
updateTitle,
isSubmitting,
status,
]
)
// Handle suggestion
const handleSuggestion = useCallback(
async (suggestion: string) => {
setIsSubmitting(true)
const optimisticId = `optimistic-${Date.now().toString()}`
const optimisticMessage = {
id: optimisticId,
content: suggestion,
role: "user" as const,
createdAt: new Date(),
}
setMessages((prev) => [...prev, optimisticMessage])
try {
const uid = await getOrCreateGuestUserId(user)
if (!uid) {
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
return
}
const allowed = await checkLimitsAndNotify(uid)
if (!allowed) {
setMessages((prev) => prev.filter((m) => m.id !== optimisticId))
return
}
const currentChatId = await ensureChatExists(uid, suggestion)
if (!currentChatId) {
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
return
}
prevChatIdRef.current = currentChatId
const options = {
body: {
chatId: currentChatId,
userId: uid,
model: selectedModel,
isAuthenticated,
systemPrompt: SYSTEM_PROMPT_DEFAULT,
},
}
append(
{
role: "user",
content: suggestion,
},
options
)
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
} catch {
setMessages((prev) => prev.filter((msg) => msg.id !== optimisticId))
toast({ title: "Failed to send suggestion", status: "error" })
} finally {
setIsSubmitting(false)
}
},
[
ensureChatExists,
selectedModel,
user,
append,
checkLimitsAndNotify,
isAuthenticated,
setMessages,
setIsSubmitting,
]
)
// Handle reload
const handleReload = useCallback(async () => {
const uid = await getOrCreateGuestUserId(user)
if (!uid) {
return
}
const options = {
body: {
chatId,
userId: uid,
model: selectedModel,
isAuthenticated,
systemPrompt: systemPrompt || SYSTEM_PROMPT_DEFAULT,
},
}
reload(options)
}, [user, chatId, selectedModel, isAuthenticated, systemPrompt, reload])
// Handle input change - now with access to the real setInput function!
const { setDraftValue } = useChatDraft(chatId)
const handleInputChange = useCallback(
(value: string) => {
setInput(value)
setDraftValue(value)
},
[setInput, setDraftValue]
)
return {
// Chat state
messages,
input,
handleSubmit,
status,
error,
reload,
stop,
setMessages,
setInput,
append,
isAuthenticated,
systemPrompt,
hasSentFirstMessageRef,
// Component state
isSubmitting,
setIsSubmitting,
hasDialogAuth,
setHasDialogAuth,
enableSearch,
setEnableSearch,
// Actions
submit,
handleSuggestion,
handleReload,
handleInputChange,
submitEdit,
}
}
```
## /app/components/chat/use-chat-operations.ts
```ts path="/app/components/chat/use-chat-operations.ts"
import { toast } from "@/components/ui/toast"
import { checkRateLimits } from "@/lib/api"
import type { Chats } from "@/lib/chat-store/types"
import { REMAINING_QUERY_ALERT_THRESHOLD } from "@/lib/config"
import { Message } from "@ai-sdk/react"
import { useCallback } from "react"
type UseChatOperationsProps = {
isAuthenticated: boolean
chatId: string | null
messages: Message[]
selectedModel: string
systemPrompt: string
createNewChat: (
userId: string,
title?: string,
model?: string,
isAuthenticated?: boolean,
systemPrompt?: string
) => Promise<Chats | undefined>
setHasDialogAuth: (value: boolean) => void
setMessages: (
messages: Message[] | ((messages: Message[]) => Message[])
) => void
setInput: (input: string) => void
}
export function useChatOperations({
isAuthenticated,
chatId,
messages,
selectedModel,
systemPrompt,
createNewChat,
setHasDialogAuth,
setMessages,
}: UseChatOperationsProps) {
// Chat utilities
const checkLimitsAndNotify = async (uid: string): Promise<boolean> => {
try {
const rateData = await checkRateLimits(uid, isAuthenticated)
if (rateData.remaining === 0 && !isAuthenticated) {
setHasDialogAuth(true)
return false
}
if (rateData.remaining === REMAINING_QUERY_ALERT_THRESHOLD) {
toast({
title: `Only ${rateData.remaining} quer${
rateData.remaining === 1 ? "y" : "ies"
} remaining today.`,
status: "info",
})
}
if (rateData.remainingPro === REMAINING_QUERY_ALERT_THRESHOLD) {
toast({
title: `Only ${rateData.remainingPro} pro quer${
rateData.remainingPro === 1 ? "y" : "ies"
} remaining today.`,
status: "info",
})
}
return true
} catch (err) {
console.error("Rate limit check failed:", err)
return false
}
}
const ensureChatExists = async (userId: string, input: string) => {
if (chatId) return chatId
if (!isAuthenticated) {
const storedGuestChatId = localStorage.getItem("guestChatId")
if (storedGuestChatId) return storedGuestChatId
}
try {
const newChat = await createNewChat(
userId,
input,
selectedModel,
isAuthenticated,
systemPrompt
)
if (!newChat) return null
if (isAuthenticated) {
window.history.pushState(null, "", `/c/${newChat.id}`)
} else {
localStorage.setItem("guestChatId", newChat.id)
}
return newChat.id
} catch (err: unknown) {
let errorMessage = "Something went wrong."
try {
const errorObj = err as { message?: string }
if (errorObj.message) {
const parsed = JSON.parse(errorObj.message)
errorMessage = parsed.error || errorMessage
}
} catch {
const errorObj = err as { message?: string }
errorMessage = errorObj.message || errorMessage
}
toast({
title: errorMessage,
status: "error",
})
return null
}
}
// Message handlers
const handleDelete = useCallback(
(id: string) => {
setMessages(messages.filter((message) => message.id !== id))
},
[messages, setMessages]
)
const handleEdit = useCallback(
(id: string, newText: string) => {
setMessages(
messages.map((message) =>
message.id === id ? { ...message, content: newText } : message
)
)
},
[messages, setMessages]
)
return {
// Utils
checkLimitsAndNotify,
ensureChatExists,
// Handlers
handleDelete,
handleEdit,
}
}
```
## /app/components/chat/use-file-upload.ts
```ts path="/app/components/chat/use-file-upload.ts"
import { toast } from "@/components/ui/toast"
import {
Attachment,
checkFileUploadLimit,
processFiles,
} from "@/lib/file-handling"
import { useCallback, useState } from "react"
export const useFileUpload = () => {
const [files, setFiles] = useState<File[]>([])
const handleFileUploads = async (
uid: string,
chatId: string
): Promise<Attachment[] | null> => {
if (files.length === 0) return []
try {
await checkFileUploadLimit(uid)
} catch (err: unknown) {
const error = err as { code?: string; message?: string }
if (error.code === "DAILY_FILE_LIMIT_REACHED") {
toast({ title: error.message || "Daily file limit reached", status: "error" })
return null
}
}
try {
const processed = await processFiles(files, chatId, uid)
setFiles([])
return processed
} catch {
toast({ title: "Failed to process files", status: "error" })
return null
}
}
const createOptimisticAttachments = (files: File[]) => {
return files.map((file) => ({
name: file.name,
contentType: file.type,
url: file.type.startsWith("image/") ? URL.createObjectURL(file) : "",
}))
}
const cleanupOptimisticAttachments = (attachments?: Array<{ url?: string }>) => {
if (!attachments) return
attachments.forEach((attachment) => {
if (attachment.url?.startsWith("blob:")) {
URL.revokeObjectURL(attachment.url)
}
})
}
const handleFileUpload = useCallback((newFiles: File[]) => {
setFiles((prev) => [...prev, ...newFiles])
}, [])
const handleFileRemove = useCallback((file: File) => {
setFiles((prev) => prev.filter((f) => f !== file))
}, [])
return {
files,
setFiles,
handleFileUploads,
createOptimisticAttachments,
cleanupOptimisticAttachments,
handleFileUpload,
handleFileRemove,
}
}
```
## /app/components/chat/use-model.ts
```ts path="/app/components/chat/use-model.ts"
import { toast } from "@/components/ui/toast"
import { Chats } from "@/lib/chat-store/types"
import { MODEL_DEFAULT } from "@/lib/config"
import type { UserProfile } from "@/lib/user/types"
import { useCallback, useState } from "react"
interface UseModelProps {
currentChat: Chats | null
user: UserProfile | null
updateChatModel?: (chatId: string, model: string) => Promise<void>
chatId: string | null
}
/**
* Hook to manage the current selected model with proper fallback logic
* Handles both cases: with existing chat (persists to DB) and without chat (local state only)
* @param currentChat - The current chat object
* @param user - The current user object
* @param updateChatModel - Function to update chat model in the database
* @param chatId - The current chat ID
* @returns Object containing selected model and handler function
*/
export function useModel({
currentChat,
user,
updateChatModel,
chatId,
}: UseModelProps) {
// Calculate the effective model based on priority: chat model > first favorite model > default
const getEffectiveModel = useCallback(() => {
const firstFavoriteModel = user?.favorite_models?.[0]
return currentChat?.model || firstFavoriteModel || MODEL_DEFAULT
}, [currentChat?.model, user?.favorite_models])
// Use local state only for temporary overrides, derive base value from props
const [localSelectedModel, setLocalSelectedModel] = useState<string | null>(
null
)
// The actual selected model: local override or computed effective model
const selectedModel = localSelectedModel || getEffectiveModel()
// Function to handle model changes with proper validation and error handling
const handleModelChange = useCallback(
async (newModel: string) => {
// For authenticated users without a chat, we can't persist yet
// but we still allow the model selection for when they create a chat
if (!user?.id && !chatId) {
// For unauthenticated users without chat, just update local state
setLocalSelectedModel(newModel)
return
}
// For authenticated users with a chat, persist the change
if (chatId && updateChatModel && user?.id) {
// Optimistically update the state
setLocalSelectedModel(newModel)
try {
await updateChatModel(chatId, newModel)
// Clear local override since it's now persisted in the chat
setLocalSelectedModel(null)
} catch (err) {
// Revert on error
setLocalSelectedModel(null)
console.error("Failed to update chat model:", err)
toast({
title: "Failed to update chat model",
status: "error",
})
throw err
}
} else if (user?.id) {
// Authenticated user but no chat yet - just update local state
// The model will be used when creating a new chat
setLocalSelectedModel(newModel)
}
},
[chatId, updateChatModel, user?.id]
)
return {
selectedModel,
handleModelChange,
}
}
```
## /app/components/chat/useAssistantMessageSelection.ts
```ts path="/app/components/chat/useAssistantMessageSelection.ts"
import { RefObject, useCallback, useEffect, useState } from "react"
type SelectionInfo = {
text: string
position: { x: number; y: number }
messageId: string
}
export const useAssistantMessageSelection = (
ref: RefObject<HTMLElement | null>,
enabled: boolean
) => {
const [selectionInfo, setSelectionInfo] = useState<SelectionInfo | null>(null)
const onSelectStart = useCallback(() => {
setSelectionInfo(null)
}, [])
const onMouseUp = useCallback(
(event: MouseEvent) => {
const selection = window.getSelection()
const selectedText = selection?.toString()
// Find the closest ancestor with data-message-id attribute for the current selection
let messageElement: HTMLElement | null = null
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (range) {
const commonAncestor = range.commonAncestorContainer
if (commonAncestor instanceof HTMLElement) {
messageElement = commonAncestor.closest("[data-message-id]")
} else if (commonAncestor.parentNode instanceof HTMLElement) {
messageElement =
commonAncestor.parentNode.closest("[data-message-id]")
}
}
const messageId = messageElement?.dataset.messageId
if (
!selectedText?.trim() ||
selectedText.trim().length < 3 ||
!selection ||
!messageId ||
!ref.current?.contains(messageElement)
) {
setSelectionInfo(null)
return
}
if (range) {
const rect = range.getBoundingClientRect()
// Constrain mouse position to the selection bounds
const constrainedX = Math.max(
rect.left,
Math.min(event.clientX, rect.right)
)
const constrainedY = Math.max(
rect.top,
Math.min(event.clientY, rect.bottom)
)
setSelectionInfo({
text: selectedText.trim(),
position: {
x: constrainedX,
y: constrainedY,
},
messageId,
})
} else {
setSelectionInfo(null)
}
},
[ref]
)
useEffect(() => {
if (!enabled) return
const currentRef = ref.current
if (currentRef) {
currentRef.addEventListener("selectstart", onSelectStart)
document.addEventListener("mouseup", onMouseUp)
return () => {
currentRef.removeEventListener("selectstart", onSelectStart)
document.removeEventListener("mouseup", onMouseUp)
}
}
}, [ref, onSelectStart, onMouseUp, enabled])
const clearSelection = useCallback(() => {
setSelectionInfo(null)
window.getSelection()?.removeAllRanges()
}, [])
return { selectionInfo, clearSelection }
}
```
## /app/components/chat/utils.ts
```ts path="/app/components/chat/utils.ts"
export const addUTM = (url: string) => {
try {
// Check if the URL is valid
const u = new URL(url)
// Ensure it's using HTTP or HTTPS protocol
if (!["http:", "https:"].includes(u.protocol)) {
return url // Return original URL for non-http(s) URLs
}
u.searchParams.set("utm_source", "zola.chat")
u.searchParams.set("utm_medium", "research")
return u.toString()
} catch {
// If URL is invalid, return the original URL without modification
return url
}
}
export const getFavicon = (url: string | null) => {
if (!url) return null
try {
// Check if the URL is valid
const urlObj = new URL(url)
// Ensure it's using HTTP or HTTPS protocol
if (!["http:", "https:"].includes(urlObj.protocol)) {
return null
}
const domain = urlObj.hostname
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} catch {
// No need to log errors for invalid URLs
return null
}
}
export const formatUrl = (url: string) => {
try {
return url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")
} catch {
return url
}
}
export const getSiteName = (url: string) => {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace(/^www\./, "")
} catch {
return url
}
}
```
## /app/components/header-go-back.tsx
```tsx path="/app/components/header-go-back.tsx"
import { ArrowLeft } from "@phosphor-icons/react"
import Link from "next/link"
export function HeaderGoBack({ href = "/" }: { href?: string }) {
return (
<header className="p-4">
<Link
href={href}
prefetch
className="text-foreground hover:bg-muted inline-flex items-center gap-1 rounded-md px-2 py-1"
>
<ArrowLeft className="text-foreground size-5" />
<span className="font-base ml-2 hidden text-sm sm:inline-block">
Back to Chat
</span>
</Link>
</header>
)
}
```
## /app/components/history/chat-preview-panel.tsx
```tsx path="/app/components/history/chat-preview-panel.tsx"
import { MessageContent } from "@/components/prompt-kit/message"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { AlertCircle, Loader2, RefreshCw } from "lucide-react"
import { useLayoutEffect, useRef, useState } from "react"
type ChatPreviewPanelProps = {
chatId: string | null
onHover?: (isHovering: boolean) => void
messages?: ChatMessage[]
isLoading?: boolean
error?: string | null
onFetchPreview?: (chatId: string) => Promise<void>
}
type ChatMessage = {
id: string
content: string
role: "user" | "assistant"
created_at: string
}
type MessageBubbleProps = {
content: string
role: "user" | "assistant"
timestamp: string
}
function MessageBubble({ content, role }: MessageBubbleProps) {
const isUser = role === "user"
if (isUser) {
return (
<div className="flex justify-end">
<div className="max-w-[70%]">
<MessageContent
className="bg-accent relative rounded-3xl px-5 py-2.5"
markdown={true}
components={{
code: ({ children }) => <>{children}</>,
pre: ({ children }) => <>{children}</>,
h1: ({ children }) => <p>{children}</p>,
h2: ({ children }) => <p>{children}</p>,
h3: ({ children }) => <p>{children}</p>,
h4: ({ children }) => <p>{children}</p>,
h5: ({ children }) => <p>{children}</p>,
h6: ({ children }) => <p>{children}</p>,
p: ({ children }) => <p>{children}</p>,
li: ({ children }) => <p>- {children}</p>,
ul: ({ children }) => <>{children}</>,
ol: ({ children }) => <>{children}</>,
}}
>
{content}
</MessageContent>
</div>
</div>
)
}
return (
<div className="flex justify-start">
<div className="max-w-[400px]">
<MessageContent
className="text-foreground bg-transparent p-0 text-sm"
markdown={true}
components={{
h1: ({ children }) => (
<div className="mb-1 text-base font-semibold">{children}</div>
),
h2: ({ children }) => (
<div className="mb-1 text-sm font-medium">{children}</div>
),
h3: ({ children }) => (
<div className="mb-1 text-sm font-medium">{children}</div>
),
h4: ({ children }) => (
<div className="text-sm font-medium">{children}</div>
),
h5: ({ children }) => (
<div className="text-sm font-medium">{children}</div>
),
h6: ({ children }) => (
<div className="text-sm font-medium">{children}</div>
),
p: ({ children }) => <div className="mb-1">{children}</div>,
li: ({ children }) => <div>• {children}</div>,
ul: ({ children }) => <div className="space-y-0.5">{children}</div>,
ol: ({ children }) => <div className="space-y-0.5">{children}</div>,
code: ({ children }) => (
<code className="bg-muted rounded px-1 text-xs">{children}</code>
),
pre: ({ children }) => (
<div className="bg-muted overflow-x-auto rounded p-2 text-xs">
{children}
</div>
),
}}
>
{content}
</MessageContent>
</div>
</div>
)
}
function LoadingState() {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading messages...</span>
</div>
</div>
)
}
function ErrorState({
error,
onRetry,
}: {
error: string
onRetry?: () => void
}) {
const isNetworkError =
error.includes("fetch") ||
error.includes("network") ||
error.includes("HTTP") ||
error.includes("Failed to fetch")
return (
<div className="flex h-full items-center justify-center p-4">
<div className="text-muted-foreground max-w-[300px] space-y-3 text-center">
<div className="flex justify-center">
<AlertCircle className="text-muted-foreground/50 h-8 w-8" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Failed to load preview</p>
<p className="text-xs break-words opacity-70">{error}</p>
</div>
{isNetworkError && onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="h-8 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
Try again
</Button>
)}
</div>
</div>
)
}
function EmptyState() {
return (
<div className="flex h-32 items-center justify-center p-4">
<p className="text-muted-foreground text-center text-sm">
No messages in this conversation yet
</p>
</div>
)
}
function DefaultState() {
return (
<div className="flex h-full items-center justify-center p-4">
<div className="text-muted-foreground space-y-2 text-center">
<p className="text-sm opacity-60">Select a conversation to preview</p>
</div>
</div>
)
}
export function ChatPreviewPanel({
chatId,
onHover,
messages = [],
isLoading = false,
error = null,
onFetchPreview,
}: ChatPreviewPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [lastChatId, setLastChatId] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
const maxRetries = 3
const shouldFetch = chatId && chatId !== lastChatId
if (shouldFetch && onFetchPreview) {
setLastChatId(chatId)
setRetryCount(0)
onFetchPreview(chatId)
}
const handleRetry = () => {
if (chatId && onFetchPreview && retryCount < maxRetries) {
setRetryCount((prev) => prev + 1)
onFetchPreview(chatId)
}
}
// Immediately scroll to bottom when chatId changes or messages load
useLayoutEffect(() => {
if (chatId && messages.length > 0 && scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector(
"[data-radix-scroll-area-viewport]"
)
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
}, [chatId, messages.length])
return (
<div
className="bg-background col-span-3 border-l"
onMouseEnter={() => onHover?.(true)}
onMouseLeave={() => onHover?.(false)}
key={chatId}
>
<div className="h-[480px]">
{!chatId && <DefaultState />}
{chatId && isLoading && <LoadingState />}
{chatId && error && !isLoading && (
<ErrorState
error={error}
onRetry={retryCount < maxRetries ? handleRetry : undefined}
/>
)}
{chatId && !isLoading && !error && messages.length === 0 && (
<EmptyState />
)}
{chatId && !isLoading && !error && messages.length > 0 && (
<ScrollArea ref={scrollAreaRef} className="h-full">
<div className="space-y-4 p-6">
<div className="flex justify-center">
<div className="text-muted-foreground bg-muted/50 rounded-full px-2 py-1 text-xs">
Last {messages.length} messages
</div>
</div>
{messages.map((message) => (
<MessageBubble
key={message.id}
content={message.content}
role={message.role}
timestamp={message.created_at}
/>
))}
</div>
<div ref={bottomRef} />
</ScrollArea>
)}
</div>
</div>
)
}
```
## /app/components/history/command-footer.tsx
```tsx path="/app/components/history/command-footer.tsx"
export function CommandFooter() {
// const [showPreview, setShowPreview] = useState(false)
// const { preferences, setShowConversationPreviews } = useUserPreferences()
return (
<div className="bg-card border-input right-0 bottom-0 left-0 flex items-center justify-between border-t px-4 py-3">
<div className="text-muted-foreground flex w-full items-center gap-2 text-xs">
<div className="flex w-full flex-1 flex-row items-center justify-between gap-1">
{/* @todo: need to work on the morph effect */}
{/* <div className="flex flex-1 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
className="hover:bg-accent inline-flex size-5 items-center justify-center rounded-sm transition-colors"
onClick={() => {
setShowPreview(!showPreview)
setShowConversationPreviews(
!preferences.showConversationPreviews
)
}}
>
<ArrowsOutSimpleIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<span>
{showPreview
? "Hide Conversation Preview"
: "Show Conversation Preview"}
</span>
</TooltipContent>
</Tooltip>
</div> */}
<div className="flex w-full flex-1 flex-row items-center gap-4">
<div className="flex flex-row items-center gap-1.5">
<div className="flex flex-row items-center gap-0.5">
<span className="border-border bg-muted inline-flex size-5 items-center justify-center rounded-sm border">
↑
</span>
<span className="border-border bg-muted inline-flex size-5 items-center justify-center rounded-sm border">
↓
</span>
</div>
<span>Navigate</span>
</div>
<div className="flex items-center gap-1.5">
<span className="border-border bg-muted inline-flex size-5 items-center justify-center rounded-sm border">
⏎
</span>
<span>Go to chat</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex flex-row items-center gap-0.5">
<span className="border-border bg-muted inline-flex size-5 items-center justify-center rounded-sm border">
⌘
</span>
<span className="border-border bg-muted inline-flex size-5 items-center justify-center rounded-sm border">
K
</span>
</div>
<span>Toggle</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="border-border bg-muted inline-flex h-5 items-center justify-center rounded-sm border px-1">
Esc
</span>
<span>Close</span>
</div>
</div>
</div>
)
}
```
## /app/components/history/drawer-history.tsx
```tsx path="/app/components/history/drawer-history.tsx"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useChats } from "@/lib/chat-store/chats/provider"
import { Chats } from "@/lib/chat-store/types"
import {
Check,
MagnifyingGlass,
PencilSimple,
TrashSimple,
X,
} from "@phosphor-icons/react"
import { Pin, PinOff } from "lucide-react"
import Link from "next/link"
import { useParams } from "next/navigation"
import React, { useCallback, useMemo, useState } from "react"
import { formatDate, groupChatsByDate } from "./utils"
type DrawerHistoryProps = {
chatHistory: Chats[]
onSaveEdit: (id: string, newTitle: string) => Promise<void>
onConfirmDelete: (id: string) => Promise<void>
trigger: React.ReactNode
isOpen: boolean
setIsOpen: (open: boolean) => void
}
export function DrawerHistory({
chatHistory,
onSaveEdit,
onConfirmDelete,
trigger,
isOpen,
setIsOpen,
}: DrawerHistoryProps) {
const { pinnedChats, togglePinned } = useChats()
const [searchQuery, setSearchQuery] = useState("")
const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState("")
const [deletingId, setDeletingId] = useState<string | null>(null)
const params = useParams<{ chatId: string }>()
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open)
if (!open) {
setSearchQuery("")
setEditingId(null)
setEditTitle("")
setDeletingId(null)
}
},
[setIsOpen]
)
const handleEdit = useCallback((chat: Chats) => {
setEditingId(chat.id)
setEditTitle(chat.title || "")
}, [])
const handleSaveEdit = useCallback(
async (id: string) => {
setEditingId(null)
await onSaveEdit(id, editTitle)
},
[editTitle, onSaveEdit]
)
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditTitle("")
}, [])
const handleDelete = useCallback((id: string) => {
setDeletingId(id)
}, [])
const handleConfirmDelete = useCallback(
async (id: string) => {
setDeletingId(null)
await onConfirmDelete(id)
},
[onConfirmDelete]
)
const handleCancelDelete = useCallback(() => {
setDeletingId(null)
}, [])
// Memoize filtered chats to avoid recalculating on every render
const filteredChat = useMemo(() => {
const query = searchQuery.toLowerCase()
return query
? chatHistory.filter((chat) =>
(chat.title || "").toLowerCase().includes(query)
)
: chatHistory
}, [chatHistory, searchQuery])
// Group chats by time periods - memoized to avoid recalculation
const groupedChats = useMemo(
() => groupChatsByDate(chatHistory, searchQuery),
[chatHistory, searchQuery]
)
// Render chat item
const renderChatItem = useCallback(
(chat: Chats) => (
<div key={chat.id}>
<div className="space-y-1.5">
{editingId === chat.id ? (
<div className="bg-accent flex items-center justify-between rounded-lg px-2 py-2.5">
<form
className="flex w-full items-center justify-between"
onSubmit={(e) => {
e.preventDefault()
handleSaveEdit(chat.id)
}}
>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-8 flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSaveEdit(chat.id)
}
}}
/>
<div className="ml-2 flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
type="submit"
>
<Check className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
type="button"
onClick={handleCancelEdit}
>
<X className="size-4" />
</Button>
</div>
</form>
</div>
) : deletingId === chat.id ? (
<div className="bg-accent flex items-center justify-between rounded-lg px-2 py-2.5">
<form
onSubmit={(e) => {
e.preventDefault()
handleConfirmDelete(chat.id)
}}
className="flex w-full items-center justify-between"
>
<div className="flex flex-1 items-center">
<span className="text-base font-normal">{chat.title}</span>
<input
type="text"
className="sr-only"
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
handleCancelDelete()
} else if (e.key === "Enter") {
e.preventDefault()
handleConfirmDelete(chat.id)
}
}}
/>
</div>
<div className="ml-2 flex gap-1">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-8"
type="submit"
>
<Check className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-8"
onClick={handleCancelDelete}
type="button"
>
<X className="size-4" />
</Button>
</div>
</form>
</div>
) : (
<div
className="group flex items-center justify-between rounded-lg px-2 py-1.5"
onClick={() => {
if (params.chatId === chat.id) {
handleOpenChange(false)
}
}}
>
<Link
href={`/c/${chat.id}`}
key={chat.id}
className="flex flex-1 flex-col items-start"
prefetch
>
<span className="line-clamp-1 text-base font-normal">
{chat.title || "Untitled Chat"}
</span>
<span className="mr-2 text-xs font-normal text-gray-500">
{formatDate(chat?.updated_at || chat?.created_at)}
</span>
</Link>
<div className="flex items-center">
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-8"
onClick={(e) => {
e.preventDefault()
togglePinned(chat.id, !chat.pinned)
}}
type="button"
aria-label={chat.pinned ? "Unpin" : "Pin"}
>
{chat.pinned ? (
<PinOff className="size-4 stroke-[1.5px]" />
) : (
<Pin className="size-4 stroke-[1.5px]" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-8"
onClick={(e) => {
e.preventDefault()
handleEdit(chat)
}}
type="button"
>
<PencilSimple className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-8"
onClick={(e) => {
e.preventDefault()
handleDelete(chat.id)
}}
type="button"
>
<TrashSimple className="size-4" />
</Button>
</div>
</div>
</div>
)}
</div>
</div>
),
[
handleOpenChange,
params.chatId,
editingId,
deletingId,
editTitle,
handleSaveEdit,
handleCancelEdit,
handleConfirmDelete,
handleCancelDelete,
handleEdit,
handleDelete,
togglePinned,
]
)
return (
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
</TooltipTrigger>
<TooltipContent>History</TooltipContent>
</Tooltip>
<DrawerContent>
<div className="flex h-dvh max-h-[80vh] flex-col">
<div className="border-b p-4 pb-3">
<div className="relative">
<Input
placeholder="Search..."
className="rounded-lg py-1.5 pl-8 text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<MagnifyingGlass className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 transform text-gray-400" />
</div>
</div>
<ScrollArea className="flex-1 overflow-auto">
<div className="flex flex-col space-y-6 px-4 pt-4 pb-8">
{filteredChat.length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-sm">
No chat history found.
</div>
) : searchQuery ? (
// When searching, display a flat list without grouping
<div className="space-y-2">
{filteredChat.map((chat) => renderChatItem(chat))}
</div>
) : (
<>
{pinnedChats.length > 0 && (
<div className="space-y-0.5">
<h3 className="text-muted-foreground flex items-center gap-1 pl-2 text-sm font-medium">
<Pin className="size-3" />
Pinned
</h3>
<div className="space-y-2">
{pinnedChats.map((chat) => renderChatItem(chat))}
</div>
</div>
)}
{groupedChats?.map((group) => (
<div key={group.name} className="space-y-0.5">
<h3 className="text-muted-foreground pl-2 text-sm font-medium">
{group.name}
</h3>
<div className="space-y-2">
{group.chats.map((chat) => renderChatItem(chat))}
</div>
</div>
))}
</>
)}
</div>
</ScrollArea>
</div>
</DrawerContent>
</Drawer>
)
}
```
## /app/components/history/history-trigger.tsx
```tsx path="/app/components/history/history-trigger.tsx"
"use client"
import { useBreakpoint } from "@/app/hooks/use-breakpoint"
import { useChats } from "@/lib/chat-store/chats/provider"
import { useMessages } from "@/lib/chat-store/messages/provider"
import { useChatSession } from "@/lib/chat-store/session/provider"
import { cn } from "@/lib/utils"
import { ListMagnifyingGlass } from "@phosphor-icons/react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { CommandHistory } from "./command-history"
import { DrawerHistory } from "./drawer-history"
type HistoryTriggerProps = {
hasSidebar: boolean
classNameTrigger?: string
icon?: React.ReactNode
label?: React.ReactNode | string
hasPopover?: boolean
}
export function HistoryTrigger({
hasSidebar,
classNameTrigger,
icon,
label,
hasPopover = true,
}: HistoryTriggerProps) {
const isMobile = useBreakpoint(768)
const router = useRouter()
const { chats, updateTitle, deleteChat } = useChats()
const { deleteMessages } = useMessages()
const [isOpen, setIsOpen] = useState(false)
const { chatId } = useChatSession()
const handleSaveEdit = async (id: string, newTitle: string) => {
await updateTitle(id, newTitle)
}
const handleConfirmDelete = async (id: string) => {
if (id === chatId) {
setIsOpen(false)
}
await deleteMessages()
await deleteChat(id, chatId!, () => router.push("/"))
}
const defaultTrigger = (
<button
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-muted bg-background pointer-events-auto rounded-full p-1.5 transition-colors",
hasSidebar ? "hidden" : "block",
classNameTrigger
)}
type="button"
onClick={() => setIsOpen(true)}
aria-label="Search"
tabIndex={isMobile ? -1 : 0}
>
{icon || <ListMagnifyingGlass size={24} />}
{label}
</button>
)
if (isMobile) {
return (
<DrawerHistory
chatHistory={chats}
onSaveEdit={handleSaveEdit}
onConfirmDelete={handleConfirmDelete}
trigger={defaultTrigger}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
)
}
return (
<CommandHistory
chatHistory={chats}
onSaveEdit={handleSaveEdit}
onConfirmDelete={handleConfirmDelete}
trigger={defaultTrigger}
isOpen={isOpen}
setIsOpen={setIsOpen}
onOpenChange={setIsOpen}
hasPopover={hasPopover}
/>
)
}
```
## /app/components/layout/app-info/app-info-content.tsx
```tsx path="/app/components/layout/app-info/app-info-content.tsx"
export function AppInfoContent() {
return (
<div className="space-y-4">
<p className="text-foreground leading-relaxed">
<span className="font-medium">Zola</span> is the open-source interface
for AI chat.
<br />
Multi-model, BYOK-ready, and fully self-hostable.
<br />
Use Claude, OpenAI, Gemini, local models, and more, all in one place.
<br />
</p>
<p className="text-foreground leading-relaxed">
The code is available on{" "}
<a
href="https://github.com/ibelick/zola"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
GitHub
</a>
.
</p>
</div>
)
}
```
## /app/components/layout/button-new-chat.tsx
```tsx path="/app/components/layout/button-new-chat.tsx"
"use client"
import { useKeyShortcut } from "@/app/hooks/use-key-shortcut"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { NotePencilIcon } from "@phosphor-icons/react/dist/ssr"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
export function ButtonNewChat() {
const pathname = usePathname()
const router = useRouter()
useKeyShortcut(
(e) => (e.key === "u" || e.key === "U") && e.metaKey && e.shiftKey,
() => router.push("/")
)
if (pathname === "/") return null
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className="text-muted-foreground hover:text-foreground hover:bg-muted bg-background rounded-full p-1.5 transition-colors"
prefetch
aria-label="New Chat"
>
<NotePencilIcon size={24} />
</Link>
</TooltipTrigger>
<TooltipContent>New Chat ⌘⇧U</TooltipContent>
</Tooltip>
)
}
```
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.