├── .dockerignore ├── .env.example ├── .github ├── dependabot.yml └── workflows │ ├── ci-cd.yml │ ├── codacy.yml │ └── codeql.yml ├── .gitignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── INSTALL.md ├── LICENSE ├── README.md ├── app ├── agents │ ├── [...agentSlug] │ │ └── page.tsx │ └── page.tsx ├── api │ ├── chat │ │ ├── api.ts │ │ ├── db.ts │ │ ├── route.ts │ │ └── utils.ts │ ├── create-agent │ │ └── route.ts │ ├── create-chat │ │ ├── api.ts │ │ └── route.ts │ ├── create-guest │ │ └── route.ts │ ├── csrf │ │ └── route.ts │ ├── delete-agent │ │ └── route.ts │ ├── developer-tools │ │ └── route.ts │ ├── health │ │ └── route.ts │ ├── models │ │ └── route.ts │ ├── rate-limits │ │ ├── api.ts │ │ └── route.ts │ ├── tools-available │ │ └── route.ts │ ├── update-chat-agent │ │ └── route.ts │ └── update-chat-model │ │ └── route.ts ├── auth │ ├── callback │ │ └── route.ts │ ├── error │ │ └── page.tsx │ ├── login-page.tsx │ ├── login │ │ └── actions.ts │ └── page.tsx ├── c │ └── [chatId] │ │ └── page.tsx ├── components │ ├── agents │ │ ├── agent-card.tsx │ │ ├── agent-detail.tsx │ │ ├── agent-featured-section.tsx │ │ ├── agents-page.tsx │ │ ├── dialog-agent.tsx │ │ ├── dialog-create-agent │ │ │ ├── create-agent-form.tsx │ │ │ ├── dialog-trigger-create-agent.tsx │ │ │ └── tools-section.tsx │ │ ├── research-section.tsx │ │ └── user-agent-section.tsx │ ├── chat-input │ │ ├── agent-command.tsx │ │ ├── agents.tsx │ │ ├── button-file-upload.tsx │ │ ├── button-search.tsx │ │ ├── chat-input.tsx │ │ ├── file-items.tsx │ │ ├── file-list.tsx │ │ ├── popover-content-auth.tsx │ │ ├── selected-agent.tsx │ │ ├── suggestions.tsx │ │ ├── use-agent-command.ts │ │ └── use-search-agent.ts │ ├── chat │ │ ├── chat.tsx │ │ ├── conversation.tsx │ │ ├── dialog-auth.tsx │ │ ├── feedback-widget.tsx │ │ ├── get-sources.ts │ │ ├── link-markdown.tsx │ │ ├── message-assistant.tsx │ │ ├── message-user.tsx │ │ ├── message.tsx │ │ ├── reasoning.tsx │ │ ├── search-images.tsx │ │ ├── sources-list.tsx │ │ ├── tool-invocation.tsx │ │ ├── use-chat-handlers.ts │ │ ├── use-chat-utils.ts │ │ ├── use-file-upload.ts │ │ └── utils.ts │ ├── header-go-back.tsx │ ├── history │ │ ├── command-history.tsx │ │ ├── drawer-history.tsx │ │ ├── history-trigger.tsx │ │ └── utils.ts │ ├── layout │ │ ├── agent-link.tsx │ │ ├── app-info │ │ │ ├── app-info-content.tsx │ │ │ └── app-info-trigger.tsx │ │ ├── button-new-chat.tsx │ │ ├── dialog-publish.tsx │ │ ├── feedback │ │ │ └── feedback-trigger.tsx │ │ ├── header-agent.tsx │ │ ├── header-sidebar-trigger.tsx │ │ ├── header.tsx │ │ ├── layout-app.tsx │ │ ├── settings │ │ │ ├── appearance │ │ │ │ ├── index.tsx │ │ │ │ ├── interaction-preferences.tsx │ │ │ │ ├── layout-settings.tsx │ │ │ │ └── theme-selection.tsx │ │ │ ├── connections │ │ │ │ ├── connections-placeholder.tsx │ │ │ │ ├── developer-tools.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── provider-settings.tsx │ │ │ ├── general │ │ │ │ ├── account-management.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── model-preferences.tsx │ │ │ │ ├── system-prompt.tsx │ │ │ │ └── user-profile.tsx │ │ │ ├── settings-content.tsx │ │ │ └── settings-trigger.tsx │ │ ├── sidebar │ │ │ ├── app-sidebar.tsx │ │ │ ├── dialog-delete-chat.tsx │ │ │ ├── sidebar-item-menu.tsx │ │ │ ├── sidebar-item.tsx │ │ │ └── sidebar-list.tsx │ │ └── user-menu.tsx │ └── suggestions │ │ └── prompt-system.tsx ├── favicon.ico ├── globals.css ├── hooks │ ├── use-breakpoint.ts │ ├── use-chat-draft.ts │ ├── use-click-outside.tsx │ └── use-mobile.ts ├── layout-client.tsx ├── layout.tsx ├── not-found.tsx ├── opengraph-image.alt ├── opengraph-image.jpg ├── p │ └── [chatId] │ │ ├── article.tsx │ │ ├── header.tsx │ │ └── page.tsx ├── page.tsx └── types │ ├── agent.ts │ ├── api.types.ts │ ├── database.types.ts │ └── user.ts ├── components.json ├── components ├── common │ ├── button-copy.tsx │ ├── feedback-form.tsx │ └── model-selector │ │ ├── base.tsx │ │ ├── pro-dialog.tsx │ │ └── sub-menu.tsx ├── icons │ ├── anthropic.tsx │ ├── claude.tsx │ ├── deepseek.tsx │ ├── gemini.tsx │ ├── google.tsx │ ├── grok.tsx │ ├── mistral.tsx │ ├── ollama.tsx │ ├── openai.tsx │ ├── openrouter.tsx │ ├── x.tsx │ └── xai.tsx ├── motion-primitives │ ├── morphing-dialog.tsx │ ├── morphing-popover.tsx │ ├── progressive-blur.tsx │ ├── text-morph.tsx │ └── useClickOutside.tsx ├── prompt-kit │ ├── chat-container.tsx │ ├── code-block.tsx │ ├── file-upload.tsx │ ├── loader.tsx │ ├── markdown.tsx │ ├── message.tsx │ ├── prompt-input.tsx │ ├── prompt-suggestion.tsx │ ├── reasoning.tsx │ ├── response-stream.tsx │ └── scroll-button.tsx ├── settings │ └── settings-dialog.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── hover-card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ └── tooltip.tsx ├── docker-compose.ollama.yml ├── docker-compose.yml ├── eslint.config.mjs ├── lib ├── agent-store │ ├── api.ts │ ├── provider.tsx │ └── utils.ts ├── agents │ ├── load-agent.ts │ ├── local-agents.ts │ └── utils.ts ├── api.ts ├── chat-store │ ├── chats │ │ ├── api.ts │ │ └── provider.tsx │ ├── messages │ │ ├── api.ts │ │ └── provider.tsx │ ├── persist.ts │ ├── session │ │ └── provider.tsx │ └── types.ts ├── config.ts ├── csrf.ts ├── fetch.ts ├── file-handling.ts ├── mcp │ ├── load-mcp-from-local.ts │ └── load-mcp-from-url.ts ├── models │ ├── data │ │ ├── claude.ts │ │ ├── deepseek.ts │ │ ├── gemini.ts │ │ ├── grok.ts │ │ ├── llama.ts │ │ ├── mistral.ts │ │ ├── ollama.ts │ │ └── openai.ts │ ├── index.ts │ └── types.ts ├── motion.ts ├── openproviders │ ├── env.ts │ ├── index.ts │ ├── provider-map.ts │ └── types.ts ├── providers │ └── index.ts ├── routes.ts ├── sanitize.ts ├── server │ └── api.ts ├── settings-store │ ├── store.ts │ └── types.ts ├── supabase │ ├── client.ts │ ├── config.ts │ ├── server-guest.ts │ └── server.ts ├── tools │ ├── exa │ │ ├── crawl │ │ │ ├── config.ts │ │ │ ├── run.ts │ │ │ └── tool.ts │ │ ├── imageSearch │ │ │ ├── config.ts │ │ │ ├── run.ts │ │ │ └── tool.ts │ │ ├── index.ts │ │ └── webSearch │ │ │ ├── config.ts │ │ │ ├── run.ts │ │ │ └── tool.ts │ └── index.ts ├── usage.ts ├── user-preference-store │ └── provider.tsx ├── user-store │ ├── api.ts │ └── provider.tsx ├── user │ └── api.ts └── utils.ts ├── middleware.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── banner_cloud.jpg ├── banner_forest.jpg ├── banner_ocean.jpg ├── button │ └── github.svg └── cover_zola.webp ├── tsconfig.json └── utils └── supabase └── middleware.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Next.js 8 | .next 9 | out 10 | 11 | # Production 12 | build 13 | dist 14 | 15 | # Environment variables 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | # Logs 23 | *.log 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Dependency directories 38 | jspm_packages/ 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional REPL history 44 | .node_repl_history 45 | 46 | # Output of 'npm pack' 47 | *.tgz 48 | 49 | # Yarn Integrity file 50 | .yarn-integrity 51 | 52 | # parcel-bundler cache (https://parceljs.org/) 53 | .cache 54 | .parcel-cache 55 | 56 | # next.js build output 57 | .next 58 | 59 | # nuxt.js build output 60 | .nuxt 61 | 62 | # vuepress build output 63 | .vuepress/dist 64 | 65 | # Serverless directories 66 | .serverless 67 | 68 | # FuseBox cache 69 | .fusebox/ 70 | 71 | # DynamoDB Local files 72 | .dynamodb/ 73 | 74 | # IDE 75 | .vscode 76 | .idea 77 | *.swp 78 | *.swo 79 | 80 | # OS 81 | .DS_Store 82 | Thumbs.db 83 | 84 | # Git 85 | .git 86 | .gitignore 87 | 88 | # Docker 89 | Dockerfile* 90 | docker-compose* 91 | .dockerignore 92 | 93 | # CI/CD 94 | .github 95 | 96 | # Documentation 97 | README.md 98 | *.md 99 | 100 | # Testing 101 | coverage 102 | .nyc_output 103 | jest.config.js 104 | 105 | # Linting 106 | .eslintrc* 107 | .prettierrc* 108 | 109 | # TypeScript 110 | *.tsbuildinfo -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Supabase Configuration 2 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url 3 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key 4 | SUPABASE_SERVICE_ROLE=your_supabase_service_role_key 5 | 6 | # CSRF Protection (required) 7 | CSRF_SECRET=your_32_character_random_string 8 | 9 | # AI Model API Keys 10 | OPENAI_API_KEY=sk-your_openai_api_key 11 | MISTRAL_API_KEY=your_mistral_api_key 12 | GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key 13 | ANTHROPIC_API_KEY=your_anthropic_api_key 14 | XAI_API_KEY=your_xai_api_key 15 | OPENROUTER_API_KEY=your_openrouter_api_key 16 | 17 | # Ollama Configuration (for local AI models) 18 | OLLAMA_BASE_URL=http://localhost:11434 19 | 20 | # Developer Tools (optional) 21 | EXA_API_KEY=your_exa_api_key 22 | GITHUB_TOKEN=your_github_token 23 | 24 | # Production Configuration (optional) 25 | NEXT_PUBLIC_VERCEL_URL=your_production_domain 26 | 27 | # Development Tools (optional) 28 | ANALYZE=false 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" # Less frequent 7 | open-pull-requests-limit: 2 # Fewer PRs 8 | commit-message: 9 | prefix: "deps" # Clean commit history 10 | -------------------------------------------------------------------------------- /.github/workflows/codacy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow checks out code, performs a Codacy security scan 7 | # and integrates the results with the 8 | # GitHub Advanced Security code scanning feature. For more information on 9 | # the Codacy security scan action usage and parameters, see 10 | # https://github.com/codacy/codacy-analysis-cli-action. 11 | # For more information on Codacy Analysis CLI in general, see 12 | # https://github.com/codacy/codacy-analysis-cli. 13 | 14 | name: Codacy Security Scan 15 | 16 | on: 17 | push: 18 | branches: [ "main" ] 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: [ "main" ] 22 | schedule: 23 | - cron: '39 10 * * 6' 24 | 25 | permissions: 26 | contents: read 27 | 28 | jobs: 29 | codacy-security-scan: 30 | permissions: 31 | contents: read # for actions/checkout to fetch code 32 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 33 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 34 | name: Codacy Security Scan 35 | runs-on: ubuntu-latest 36 | steps: 37 | # Checkout the repository to the GitHub Actions runner 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | 41 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 42 | - name: Run Codacy Analysis CLI 43 | uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b 44 | with: 45 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 46 | # You can also omit the token and run the tools that support default configurations 47 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 48 | verbose: true 49 | output: results.sarif 50 | format: sarif 51 | # Adjust severity of non-security issues 52 | gh-code-scanning-compat: true 53 | # Force 0 exit code to allow SARIF file generation 54 | # This will handover control about PR rejection to the GitHub side 55 | max-allowed-issues: 2147483647 56 | 57 | # Upload the SARIF file generated in the previous step 58 | - name: Upload SARIF results file 59 | uses: github/codeql-action/upload-sarif@v3 60 | with: 61 | sarif_file: results.sarif 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | .env.local 35 | .env.development.local 36 | .env.test.local 37 | .env.production.local 38 | 39 | 40 | # vercel 41 | .vercel 42 | 43 | # typescript 44 | *.tsbuildinfo 45 | next-env.d.ts 46 | 47 | # env 48 | .env 49 | 50 | # bun 51 | bun.lockb 52 | bun.lock 53 | 54 | /.cursor 55 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5", 7 | "plugins": [ 8 | "@ianvs/prettier-plugin-sort-imports", 9 | "prettier-plugin-tailwindcss" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | 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. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Accepting constructive feedback gracefully 14 | - Focusing on what benefits the community 15 | - Showing empathy toward other community members 16 | 17 | Examples of unacceptable behavior include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention 20 | - Trolling, insulting or derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information without permission 23 | - Other conduct that would be inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | 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. 28 | 29 | 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. 30 | 31 | ## Scope 32 | 33 | 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. 34 | 35 | ## Enforcement 36 | 37 | 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. 38 | 39 | Maintainers who fail to enforce this Code in good faith may face consequences as determined by other core contributors. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 44 | available at [http://contributor-covenant.org/version/1/4][version] 45 | 46 | [homepage]: http://contributor-covenant.org 47 | [version]: http://contributor-covenant.org/version/1/4/ 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base Node.js image 2 | FROM node:18-alpine AS base 3 | 4 | # Install dependencies only when needed 5 | FROM base AS deps 6 | WORKDIR /app 7 | 8 | # Copy package files 9 | COPY package.json package-lock.json* ./ 10 | 11 | # Install dependencies (including devDependencies for build) 12 | RUN npm ci && npm cache clean --force 13 | 14 | # Rebuild the source code only when needed 15 | FROM base AS builder 16 | WORKDIR /app 17 | 18 | # Copy node_modules from deps 19 | COPY --from=deps /app/node_modules ./node_modules 20 | 21 | # Copy all project files 22 | COPY . . 23 | 24 | # Set Next.js telemetry to disabled 25 | ENV NEXT_TELEMETRY_DISABLED=1 26 | 27 | # Build the application 28 | RUN npm run build 29 | 30 | # Verify standalone build was created 31 | RUN ls -la .next/ && \ 32 | if [ ! -d ".next/standalone" ]; then \ 33 | echo "ERROR: .next/standalone directory not found. Make sure output: 'standalone' is set in next.config.ts"; \ 34 | exit 1; \ 35 | fi 36 | 37 | # Production image, copy all the files and run next 38 | FROM base AS runner 39 | WORKDIR /app 40 | 41 | # Set environment variables 42 | ENV NODE_ENV=production 43 | ENV NEXT_TELEMETRY_DISABLED=1 44 | 45 | # Create a non-root user 46 | RUN addgroup --system --gid 1001 nodejs && \ 47 | adduser --system --uid 1001 nextjs 48 | 49 | # Copy public assets 50 | COPY --from=builder /app/public ./public 51 | 52 | # Copy standalone application 53 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 54 | 55 | # Copy static assets 56 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 57 | 58 | # Switch to non-root user 59 | USER nextjs 60 | 61 | # Expose application port 62 | EXPOSE 3000 63 | 64 | # Set environment variable for port 65 | ENV PORT=3000 66 | ENV HOSTNAME=0.0.0.0 67 | 68 | # Health check to verify container is running properly 69 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ 70 | CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 71 | 72 | # Start the application 73 | CMD ["node", "server.js"] 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zola 2 | 3 | [zola.chat](https://zola.chat) 4 | 5 | **Zola** is the open-source interface for AI chat. 6 | 7 | [![Chat with this repo](https://zola.chat/button/github.svg)](https://zola.chat/?agent=github/ibelick/zola) 8 | 9 | ![zola screenshot](./public/cover_zola.webp) 10 | 11 | ## Features 12 | 13 | - Multi-model support: OpenAI, Mistral, Claude, Gemini, **Ollama (local models)** 14 | - File uploads with context-aware answers 15 | - Clean, responsive UI with light/dark themes 16 | - Built with Tailwind, shadcn/ui, and prompt-kit 17 | - Fully open-source and self-hostable 18 | - Customizable: user system prompt, multiple layout options 19 | - **Local AI with Ollama**: Run models locally with automatic model detection 20 | 21 | ## Agent Features (WIP) 22 | 23 | - `@agent` mentions 24 | - Early tool and MCP integration for agent workflows 25 | - Foundation for more powerful, customizable agents (more coming soon) 26 | 27 | ## Quick Start 28 | 29 | ### Option 1: With OpenAI (Cloud) 30 | 31 | ```bash 32 | git clone https://github.com/ibelick/zola.git 33 | cd zola 34 | npm install 35 | echo "OPENAI_API_KEY=your-key" > .env.local 36 | npm run dev 37 | ``` 38 | 39 | ### Option 2: With Ollama (Local) 40 | 41 | ```bash 42 | # Install and start Ollama 43 | curl -fsSL https://ollama.ai/install.sh | sh 44 | ollama pull llama3.2 # or any model you prefer 45 | 46 | # Clone and run Zola 47 | git clone https://github.com/ibelick/zola.git 48 | cd zola 49 | npm install 50 | npm run dev 51 | ``` 52 | 53 | Zola will automatically detect your local Ollama models! 54 | 55 | ### Option 3: Docker with Ollama 56 | 57 | ```bash 58 | git clone https://github.com/ibelick/zola.git 59 | cd zola 60 | docker-compose -f docker-compose.ollama.yml up 61 | ``` 62 | 63 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ibelick/zola) 64 | 65 | To unlock features like auth, file uploads, and agents, see [INSTALL.md](./INSTALL.md). 66 | 67 | ## Built with 68 | 69 | - [prompt-kit](https://prompt-kit.com/) — AI components 70 | - [shadcn/ui](https://ui.shadcn.com) — core components 71 | - [motion-primitives](https://motion-primitives.com) — animated components 72 | - [vercel ai sdk](https://vercel.com/blog/introducing-the-vercel-ai-sdk) — model integration, AI features 73 | - [supabase](https://supabase.com) — auth and storage 74 | 75 | ## Sponsors 76 | 77 | 78 | Vercel OSS Program 79 | 80 | 81 | ## License 82 | 83 | Apache License 2.0 84 | 85 | ## Notes 86 | 87 | This is a beta release. The codebase is evolving and may change. 88 | -------------------------------------------------------------------------------- /app/agents/[...agentSlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { AgentDetail } from "@/app/components/agents/agent-detail" 2 | import { LayoutApp } from "@/app/components/layout/layout-app" 3 | import { MessagesProvider } from "@/lib/chat-store/messages/provider" 4 | import { isSupabaseEnabled } from "@/lib/supabase/config" 5 | import { createClient } from "@/lib/supabase/server" 6 | import { notFound } from "next/navigation" 7 | 8 | export default async function AgentIdPage({ 9 | params, 10 | }: { 11 | params: Promise<{ agentSlug: string | string[] }> 12 | }) { 13 | if (!isSupabaseEnabled) { 14 | notFound() 15 | } 16 | 17 | const { agentSlug: slugParts } = await params 18 | const agentSlug = Array.isArray(slugParts) ? slugParts.join("/") : slugParts 19 | 20 | const supabase = await createClient() 21 | 22 | if (!supabase) { 23 | notFound() 24 | } 25 | 26 | const { data: agent, error: agentError } = await supabase 27 | .from("agents") 28 | .select("*") 29 | .eq("slug", agentSlug) 30 | .single() 31 | 32 | if (agentError) { 33 | throw new Error(agentError.message) 34 | } 35 | 36 | const { data: agents, error: agentsError } = await supabase 37 | .from("agents") 38 | .select("*") 39 | .not("slug", "eq", agentSlug) 40 | .limit(4) 41 | 42 | if (agentsError) { 43 | throw new Error(agentsError.message) 44 | } 45 | 46 | return ( 47 | 48 | 49 |
50 | 63 |
64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/agents/page.tsx: -------------------------------------------------------------------------------- 1 | import { AgentsPage } from "@/app/components/agents/agents-page" 2 | import { LayoutApp } from "@/app/components/layout/layout-app" 3 | import { MessagesProvider } from "@/lib/chat-store/messages/provider" 4 | import { CURATED_AGENTS_SLUGS } from "@/lib/config" 5 | import { isSupabaseEnabled } from "@/lib/supabase/config" 6 | import { createClient } from "@/lib/supabase/server" 7 | import { notFound } from "next/navigation" 8 | 9 | export const dynamic = "force-dynamic" 10 | 11 | export default async function Page() { 12 | if (!isSupabaseEnabled) { 13 | notFound() 14 | } 15 | 16 | const supabase = await createClient() 17 | 18 | if (!supabase) { 19 | notFound() 20 | } 21 | 22 | const { data: userData } = await supabase.auth.getUser() 23 | 24 | const { data: curatedAgents, error: agentsError } = await supabase 25 | .from("agents") 26 | .select("*") 27 | .in("slug", CURATED_AGENTS_SLUGS) 28 | 29 | const { data: userAgents, error: userAgentsError } = userData?.user?.id 30 | ? await supabase 31 | .from("agents") 32 | .select("*") 33 | .eq("creator_id", userData?.user?.id) 34 | : { data: [], error: null } 35 | 36 | if (agentsError) { 37 | console.error(agentsError) 38 | return
Error loading agents
39 | } 40 | 41 | if (userAgentsError) { 42 | console.error(userAgentsError) 43 | return
Error loading user agents
44 | } 45 | 46 | return ( 47 | 48 | 49 | 54 | 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/api/chat/api.ts: -------------------------------------------------------------------------------- 1 | import { saveFinalAssistantMessage } from "@/app/api/chat/db" 2 | import { checkSpecialAgentUsage, incrementSpecialAgentUsage } from "@/lib/api" 3 | import { sanitizeUserInput } from "@/lib/sanitize" 4 | import { validateUserIdentity } from "@/lib/server/api" 5 | import { checkUsageByModel, incrementUsageByModel } from "@/lib/usage" 6 | import type { 7 | SupabaseClientType, 8 | ChatApiParams, 9 | LogUserMessageParams, 10 | StoreAssistantMessageParams 11 | } from "@/app/types/api.types" 12 | 13 | export async function validateAndTrackUsage({ 14 | userId, 15 | model, 16 | isAuthenticated, 17 | }: ChatApiParams): Promise { 18 | const supabase = await validateUserIdentity(userId, isAuthenticated) 19 | if (!supabase) return null 20 | 21 | await checkUsageByModel(supabase, userId, model, isAuthenticated) 22 | return supabase 23 | } 24 | 25 | export async function logUserMessage({ 26 | supabase, 27 | userId, 28 | chatId, 29 | content, 30 | attachments, 31 | model, 32 | isAuthenticated, 33 | }: LogUserMessageParams): Promise { 34 | if (!supabase) return 35 | 36 | const { error } = await supabase.from("messages").insert({ 37 | chat_id: chatId, 38 | role: "user", 39 | content: sanitizeUserInput(content), 40 | experimental_attachments: attachments, 41 | user_id: userId, 42 | }) 43 | 44 | if (error) { 45 | console.error("Error saving user message:", error) 46 | } else { 47 | await incrementUsageByModel(supabase, userId, model, isAuthenticated) 48 | } 49 | } 50 | 51 | export async function trackSpecialAgentUsage(supabase: SupabaseClientType, userId: string): Promise { 52 | if (!supabase) return 53 | await checkSpecialAgentUsage(supabase, userId) 54 | await incrementSpecialAgentUsage(supabase, userId) 55 | } 56 | 57 | export async function storeAssistantMessage({ 58 | supabase, 59 | chatId, 60 | messages, 61 | }: StoreAssistantMessageParams): Promise { 62 | if (!supabase) return 63 | try { 64 | await saveFinalAssistantMessage(supabase, chatId, messages) 65 | } catch (err) { 66 | console.error("Failed to save assistant messages:", err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/api/create-agent/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/server" 2 | import { nanoid } from "nanoid" 3 | import slugify from "slugify" 4 | 5 | function generateAgentSlug(title: string) { 6 | const base = slugify(title, { lower: true, strict: true, trim: true }) 7 | const id = nanoid(6) 8 | return `${base}-${id}` 9 | } 10 | 11 | export async function POST(request: Request) { 12 | try { 13 | const { 14 | name, 15 | description, 16 | systemPrompt, 17 | mcp_config, 18 | example_inputs, 19 | avatar_url, 20 | tools = [], 21 | remixable = false, 22 | is_public = true, 23 | max_steps = 5, 24 | } = await request.json() 25 | 26 | if (!name || !description || !systemPrompt) { 27 | return new Response( 28 | JSON.stringify({ error: "Missing required fields" }), 29 | { 30 | status: 400, 31 | } 32 | ) 33 | } 34 | 35 | const supabase = await createClient() 36 | 37 | if (!supabase) { 38 | return new Response( 39 | JSON.stringify({ error: "Supabase not available in this deployment." }), 40 | { status: 200 } 41 | ) 42 | } 43 | 44 | const { data: authData } = await supabase.auth.getUser() 45 | 46 | if (!authData?.user?.id) { 47 | return new Response(JSON.stringify({ error: "Missing userId" }), { 48 | status: 400, 49 | }) 50 | } 51 | 52 | const { data: agent, error: supabaseError } = await supabase 53 | .from("agents") 54 | .insert({ 55 | slug: generateAgentSlug(name), 56 | name, 57 | description, 58 | avatar_url, 59 | mcp_config, 60 | example_inputs, 61 | tools, 62 | remixable, 63 | is_public, 64 | system_prompt: systemPrompt, 65 | max_steps, 66 | creator_id: authData.user.id, 67 | }) 68 | .select() 69 | .single() 70 | 71 | if (supabaseError) { 72 | return new Response(JSON.stringify({ error: supabaseError.message }), { 73 | status: 500, 74 | }) 75 | } 76 | 77 | return new Response(JSON.stringify({ agent }), { status: 201 }) 78 | } catch (err: unknown) { 79 | console.error("Error in create-agent endpoint:", err) 80 | 81 | return new Response( 82 | JSON.stringify({ error: (err as Error).message || "Internal server error" }), 83 | { status: 500 } 84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/api/create-chat/api.ts: -------------------------------------------------------------------------------- 1 | import { filterLocalAgentId } from "@/lib/agents/utils" 2 | import { validateUserIdentity } from "@/lib/server/api" 3 | import { checkUsageByModel } from "@/lib/usage" 4 | 5 | type CreateChatInput = { 6 | userId: string 7 | title?: string 8 | model: string 9 | isAuthenticated: boolean 10 | agentId?: string 11 | } 12 | 13 | export async function createChatInDb({ 14 | userId, 15 | title, 16 | model, 17 | isAuthenticated, 18 | agentId, 19 | }: CreateChatInput) { 20 | // Filter out local agent IDs for database operations 21 | const dbAgentId = filterLocalAgentId(agentId) 22 | 23 | const supabase = await validateUserIdentity(userId, isAuthenticated) 24 | if (!supabase) { 25 | return { 26 | id: crypto.randomUUID(), 27 | user_id: userId, 28 | title, 29 | model, 30 | created_at: new Date().toISOString(), 31 | updated_at: new Date().toISOString(), 32 | agent_id: dbAgentId, 33 | } 34 | } 35 | 36 | await checkUsageByModel(supabase, userId, model, isAuthenticated) 37 | 38 | const insertData: any = { 39 | user_id: userId, 40 | title: title || "New Chat", 41 | model, 42 | } 43 | 44 | if (dbAgentId) { 45 | insertData.agent_id = dbAgentId 46 | } 47 | 48 | const { data, error } = await supabase 49 | .from("chats") 50 | .insert(insertData) 51 | .select("*") 52 | .single() 53 | 54 | if (error || !data) { 55 | console.error("Error creating chat:", error) 56 | return null 57 | } 58 | 59 | return data 60 | } 61 | -------------------------------------------------------------------------------- /app/api/create-chat/route.ts: -------------------------------------------------------------------------------- 1 | import { createChatInDb } from "./api" 2 | 3 | export async function POST(request: Request) { 4 | try { 5 | const { userId, title, model, isAuthenticated, agentId } = 6 | await request.json() 7 | 8 | if (!userId) { 9 | return new Response(JSON.stringify({ error: "Missing userId" }), { 10 | status: 400, 11 | }) 12 | } 13 | 14 | const chat = await createChatInDb({ 15 | userId, 16 | title, 17 | model, 18 | isAuthenticated, 19 | agentId, 20 | }) 21 | 22 | if (!chat) { 23 | return new Response( 24 | JSON.stringify({ error: "Supabase not available in this deployment." }), 25 | { status: 200 } 26 | ) 27 | } 28 | 29 | return new Response(JSON.stringify({ chat }), { status: 200 }) 30 | } catch (err: unknown) { 31 | console.error("Error in create-chat endpoint:", err) 32 | 33 | if (err instanceof Error && err.message === "DAILY_LIMIT_REACHED") { 34 | return new Response( 35 | JSON.stringify({ error: err.message, code: "DAILY_LIMIT_REACHED" }), 36 | { status: 403 } 37 | ) 38 | } 39 | 40 | return new Response( 41 | JSON.stringify({ 42 | error: (err as Error).message || "Internal server error", 43 | }), 44 | { status: 500 } 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/api/create-guest/route.ts: -------------------------------------------------------------------------------- 1 | import { createGuestServerClient } from "@/lib/supabase/server-guest" 2 | 3 | export async function POST(request: Request) { 4 | try { 5 | const { userId } = await request.json() 6 | 7 | if (!userId) { 8 | return new Response(JSON.stringify({ error: "Missing userId" }), { 9 | status: 400, 10 | }) 11 | } 12 | 13 | const supabase = await createGuestServerClient() 14 | if (!supabase) { 15 | console.log("Supabase not enabled, skipping guest creation.") 16 | return new Response( 17 | JSON.stringify({ user: { id: userId, anonymous: true } }), 18 | { 19 | status: 200, 20 | } 21 | ) 22 | } 23 | 24 | // Check if the user record already exists. 25 | let { data: userData } = await supabase 26 | .from("users") 27 | .select("*") 28 | .eq("id", userId) 29 | .maybeSingle() 30 | 31 | if (!userData) { 32 | const { data, error } = await supabase 33 | .from("users") 34 | .insert({ 35 | id: userId, 36 | email: `${userId}@anonymous.example`, 37 | anonymous: true, 38 | message_count: 0, 39 | premium: false, 40 | created_at: new Date().toISOString(), 41 | }) 42 | .select("*") 43 | .single() 44 | 45 | if (error || !data) { 46 | console.error("Error creating guest user:", error) 47 | return new Response( 48 | JSON.stringify({ 49 | error: "Failed to create guest user", 50 | details: error?.message, 51 | }), 52 | { status: 500 } 53 | ) 54 | } 55 | 56 | userData = data 57 | } 58 | 59 | return new Response(JSON.stringify({ user: userData }), { status: 200 }) 60 | } catch (err: unknown) { 61 | console.error("Error in create-guest endpoint:", err) 62 | 63 | return new Response( 64 | JSON.stringify({ error: (err as Error).message || "Internal server error" }), 65 | { status: 500 } 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/api/csrf/route.ts: -------------------------------------------------------------------------------- 1 | import { generateCsrfToken } from "@/lib/csrf" 2 | import { cookies } from "next/headers" 3 | import { NextResponse } from "next/server" 4 | 5 | export async function GET() { 6 | const token = generateCsrfToken() 7 | const cookieStore = await cookies() 8 | cookieStore.set("csrf_token", token, { 9 | httpOnly: false, 10 | secure: true, 11 | path: "/", 12 | }) 13 | 14 | return NextResponse.json({ ok: true }) 15 | } 16 | -------------------------------------------------------------------------------- /app/api/delete-agent/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/server" 2 | 3 | export async function DELETE(request: Request) { 4 | try { 5 | const { slug } = await request.json() 6 | 7 | if (!slug) { 8 | return new Response(JSON.stringify({ error: "Missing agent slug" }), { 9 | status: 400, 10 | }) 11 | } 12 | 13 | const supabase = await createClient() 14 | 15 | if (!supabase) { 16 | return new Response( 17 | JSON.stringify({ error: "Supabase not available in this deployment." }), 18 | { status: 500 } 19 | ) 20 | } 21 | 22 | // Get the authenticated user 23 | const { data: authData, error: authError } = await supabase.auth.getUser() 24 | 25 | if (authError || !authData?.user?.id) { 26 | return new Response( 27 | JSON.stringify({ error: "Authentication required" }), 28 | { status: 401 } 29 | ) 30 | } 31 | 32 | // First, check if the agent exists and the user owns it 33 | const { data: agent, error: fetchError } = await supabase 34 | .from("agents") 35 | .select("id, creator_id, name") 36 | .eq("slug", slug) 37 | .single() 38 | 39 | if (fetchError || !agent) { 40 | return new Response(JSON.stringify({ error: "Agent not found" }), { 41 | status: 404, 42 | }) 43 | } 44 | 45 | if (agent.creator_id !== authData.user.id) { 46 | return new Response( 47 | JSON.stringify({ 48 | error: "You can only delete agents that you created", 49 | }), 50 | { status: 403 } 51 | ) 52 | } 53 | 54 | // Delete the agent 55 | const { error: deleteError } = await supabase 56 | .from("agents") 57 | .delete() 58 | .eq("slug", slug) 59 | .eq("creator_id", authData.user.id) // Extra safety check 60 | 61 | if (deleteError) { 62 | console.error("Error deleting agent:", deleteError) 63 | return new Response( 64 | JSON.stringify({ 65 | error: "Failed to delete agent", 66 | details: deleteError.message, 67 | }), 68 | { status: 500 } 69 | ) 70 | } 71 | 72 | return new Response( 73 | JSON.stringify({ message: "Agent deleted successfully" }), 74 | { status: 200 } 75 | ) 76 | } catch (err: unknown) { 77 | console.error("Error in delete-agent endpoint:", err) 78 | 79 | return new Response( 80 | JSON.stringify({ error: (err as Error).message || "Internal server error" }), 81 | { status: 500 } 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/api/developer-tools/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | export async function GET() { 4 | // Only allow in development 5 | if (process.env.NODE_ENV !== "development") { 6 | return NextResponse.json( 7 | { error: "Not available in production" }, 8 | { status: 403 } 9 | ) 10 | } 11 | 12 | const getMaskedKey = (key: string | undefined) => { 13 | if (!key || key.length < 4) return null 14 | return `${"*".repeat(8)}${key.slice(-3)}` 15 | } 16 | 17 | const tools = [ 18 | { 19 | id: "exa", 20 | name: "Exa", 21 | icon: "🧠", 22 | description: "Use Exa to power search-based agents.", 23 | envKeys: ["EXA_API_KEY"], 24 | connected: Boolean(process.env.EXA_API_KEY), 25 | maskedKey: getMaskedKey(process.env.EXA_API_KEY), 26 | sampleEnv: `EXA_API_KEY=your_key_here`, 27 | }, 28 | { 29 | id: "github", 30 | name: "GitHub", 31 | icon: "🐙", 32 | description: "Use GitHub Search in your agents.", 33 | envKeys: ["GITHUB_TOKEN"], 34 | connected: Boolean(process.env.GITHUB_TOKEN), 35 | maskedKey: getMaskedKey(process.env.GITHUB_TOKEN), 36 | sampleEnv: `GITHUB_TOKEN=your_token_here`, 37 | }, 38 | ] 39 | 40 | return NextResponse.json({ tools }) 41 | } 42 | -------------------------------------------------------------------------------- /app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export async function GET() { 4 | return NextResponse.json( 5 | { 6 | status: 'ok', 7 | timestamp: new Date().toISOString(), 8 | uptime: process.uptime() 9 | }, 10 | { status: 200 } 11 | ) 12 | } -------------------------------------------------------------------------------- /app/api/models/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getAllModels, refreshModelsCache } from "@/lib/models" 3 | 4 | export async function GET() { 5 | try { 6 | const models = await getAllModels() 7 | 8 | return new Response(JSON.stringify({ models }), { 9 | status: 200, 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | }) 14 | } catch (error) { 15 | console.error("Error fetching models:", error) 16 | return new Response( 17 | JSON.stringify({ error: "Failed to fetch models" }), 18 | { 19 | status: 500, 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | } 24 | ) 25 | } 26 | } 27 | 28 | export async function POST() { 29 | try { 30 | // Refresh the models cache 31 | refreshModelsCache() 32 | const models = await getAllModels() 33 | 34 | return NextResponse.json({ 35 | message: "Models cache refreshed", 36 | models, 37 | timestamp: new Date().toISOString(), 38 | count: models.length, 39 | }) 40 | } catch (error) { 41 | console.error("Failed to refresh models:", error) 42 | return NextResponse.json( 43 | { error: "Failed to refresh models" }, 44 | { status: 500 } 45 | ) 46 | } 47 | } -------------------------------------------------------------------------------- /app/api/rate-limits/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AUTH_DAILY_MESSAGE_LIMIT, 3 | DAILY_LIMIT_PRO_MODELS, 4 | NON_AUTH_DAILY_MESSAGE_LIMIT, 5 | } from "@/lib/config" 6 | import { validateUserIdentity } from "@/lib/server/api" 7 | 8 | export async function getMessageUsage( 9 | userId: string, 10 | isAuthenticated: boolean 11 | ) { 12 | const supabase = await validateUserIdentity(userId, isAuthenticated) 13 | if (!supabase) return null 14 | 15 | const { data, error } = await supabase 16 | .from("users") 17 | .select("daily_message_count, daily_pro_message_count") 18 | .eq("id", userId) 19 | .maybeSingle() 20 | 21 | if (error || !data) { 22 | throw new Error(error?.message || "Failed to fetch message usage") 23 | } 24 | 25 | const dailyLimit = isAuthenticated 26 | ? AUTH_DAILY_MESSAGE_LIMIT 27 | : NON_AUTH_DAILY_MESSAGE_LIMIT 28 | 29 | const dailyCount = data.daily_message_count || 0 30 | const dailyProCount = data.daily_pro_message_count || 0 31 | 32 | return { 33 | dailyCount, 34 | dailyProCount, 35 | dailyLimit, 36 | remaining: dailyLimit - dailyCount, 37 | remainingPro: DAILY_LIMIT_PRO_MODELS - dailyProCount, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/api/rate-limits/route.ts: -------------------------------------------------------------------------------- 1 | import { getMessageUsage } from "./api" 2 | 3 | export async function GET(req: Request) { 4 | const { searchParams } = new URL(req.url) 5 | const userId = searchParams.get("userId") 6 | const isAuthenticated = searchParams.get("isAuthenticated") === "true" 7 | 8 | if (!userId) { 9 | return new Response(JSON.stringify({ error: "Missing userId" }), { 10 | status: 400, 11 | }) 12 | } 13 | 14 | try { 15 | const usage = await getMessageUsage(userId, isAuthenticated) 16 | 17 | if (!usage) { 18 | return new Response( 19 | JSON.stringify({ error: "Supabase not available in this deployment." }), 20 | { status: 200 } 21 | ) 22 | } 23 | 24 | return new Response(JSON.stringify(usage), { status: 200 }) 25 | } catch (err: unknown) { 26 | return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/api/tools-available/route.ts: -------------------------------------------------------------------------------- 1 | import { TOOL_REGISTRY } from "@/lib/tools" 2 | import { NextResponse } from "next/server" 3 | 4 | export async function GET() { 5 | const availableToolIds = Object.entries(TOOL_REGISTRY) 6 | .filter(([, tool]) => tool?.isAvailable?.()) 7 | .map(([id]) => id) 8 | 9 | return NextResponse.json({ available: availableToolIds }) 10 | } 11 | -------------------------------------------------------------------------------- /app/api/update-chat-agent/route.ts: -------------------------------------------------------------------------------- 1 | import { filterLocalAgentId } from "@/lib/agents/utils" 2 | import { validateUserIdentity } from "@/lib/server/api" 3 | 4 | export async function POST(request: Request) { 5 | try { 6 | const { userId, chatId, agentId, isAuthenticated } = await request.json() 7 | 8 | if (!userId || !chatId) { 9 | return new Response( 10 | JSON.stringify({ error: "Missing userId or chatId" }), 11 | { status: 400 } 12 | ) 13 | } 14 | 15 | // Filter out local agent IDs for database operations 16 | const dbAgentId = filterLocalAgentId(agentId) 17 | 18 | const supabase = await validateUserIdentity(userId, isAuthenticated) 19 | 20 | if (!supabase) { 21 | console.log("Supabase not enabled, skipping agent update") 22 | return new Response( 23 | JSON.stringify({ chat: { id: chatId, agent_id: dbAgentId } }), 24 | { 25 | status: 200, 26 | } 27 | ) 28 | } 29 | 30 | const { data, error: updateError } = await supabase 31 | .from("chats") 32 | .update({ agent_id: dbAgentId || null }) 33 | .eq("id", chatId) 34 | .select() 35 | .single() 36 | 37 | if (updateError) { 38 | return new Response(JSON.stringify({ error: "Failed to update chat" }), { 39 | status: 500, 40 | }) 41 | } 42 | 43 | return new Response(JSON.stringify({ chat: data }), { 44 | status: 200, 45 | }) 46 | } catch (error) { 47 | console.error("Error updating chat agent:", error) 48 | return new Response( 49 | JSON.stringify({ error: "Failed to update chat agent" }), 50 | { status: 500 } 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/api/update-chat-model/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/server" 2 | 3 | export async function POST(request: Request) { 4 | try { 5 | const supabase = await createClient() 6 | const { chatId, model } = await request.json() 7 | 8 | if (!chatId || !model) { 9 | return new Response( 10 | JSON.stringify({ error: "Missing chatId or model" }), 11 | { status: 400 } 12 | ) 13 | } 14 | 15 | // If Supabase is not available, we still return success 16 | if (!supabase) { 17 | console.log("Supabase not enabled, skipping DB update") 18 | return new Response(JSON.stringify({ success: true }), { status: 200 }) 19 | } 20 | 21 | const { error } = await supabase 22 | .from("chats") 23 | .update({ model }) 24 | .eq("id", chatId) 25 | 26 | if (error) { 27 | console.error("Error updating chat model:", error) 28 | return new Response( 29 | JSON.stringify({ 30 | error: "Failed to update chat model", 31 | details: error.message, 32 | }), 33 | { status: 500 } 34 | ) 35 | } 36 | 37 | return new Response(JSON.stringify({ success: true }), { 38 | status: 200, 39 | }) 40 | } catch (err: unknown) { 41 | console.error("Error in update-chat-model endpoint:", err) 42 | return new Response( 43 | JSON.stringify({ error: (err as Error).message || "Internal server error" }), 44 | { status: 500 } 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { MODEL_DEFAULT } from "@/lib/config" 2 | import { isSupabaseEnabled } from "@/lib/supabase/config" 3 | import { createClient } from "@/lib/supabase/server" 4 | import { createGuestServerClient } from "@/lib/supabase/server-guest" 5 | import { NextResponse } from "next/server" 6 | 7 | export async function GET(request: Request) { 8 | const { searchParams, origin } = new URL(request.url) 9 | const code = searchParams.get("code") 10 | const next = searchParams.get("next") ?? "/" 11 | 12 | if (!isSupabaseEnabled) { 13 | return NextResponse.redirect( 14 | `${origin}/auth/error?message=${encodeURIComponent("Supabase is not enabled in this deployment.")}` 15 | ) 16 | } 17 | 18 | if (!code) { 19 | return NextResponse.redirect( 20 | `${origin}/auth/error?message=${encodeURIComponent("Missing authentication code")}` 21 | ) 22 | } 23 | 24 | const supabase = await createClient() 25 | const supabaseAdmin = await createGuestServerClient() 26 | 27 | if (!supabase || !supabaseAdmin) { 28 | return NextResponse.redirect( 29 | `${origin}/auth/error?message=${encodeURIComponent("Supabase is not enabled in this deployment.")}` 30 | ) 31 | } 32 | 33 | const { data, error } = await supabase.auth.exchangeCodeForSession(code) 34 | 35 | if (error) { 36 | console.error("Auth error:", error) 37 | return NextResponse.redirect( 38 | `${origin}/auth/error?message=${encodeURIComponent(error.message)}` 39 | ) 40 | } 41 | 42 | const user = data?.user 43 | if (!user || !user.id || !user.email) { 44 | return NextResponse.redirect( 45 | `${origin}/auth/error?message=${encodeURIComponent("Missing user info")}` 46 | ) 47 | } 48 | 49 | try { 50 | // Try to insert user only if not exists 51 | const { error: insertError } = await supabaseAdmin.from("users").insert({ 52 | id: user.id, 53 | email: user.email, 54 | created_at: new Date().toISOString(), 55 | message_count: 0, 56 | premium: false, 57 | preferred_model: MODEL_DEFAULT, 58 | }) 59 | 60 | if (insertError && insertError.code !== "23505") { 61 | console.error("Error inserting user:", insertError) 62 | } 63 | } catch (err) { 64 | console.error("Unexpected user insert error:", err) 65 | } 66 | 67 | const host = request.headers.get("host") 68 | const protocol = host?.includes("localhost") ? "http" : "https" 69 | 70 | const redirectUrl = `${protocol}://${host}${next}` 71 | 72 | return NextResponse.redirect(redirectUrl) 73 | } 74 | -------------------------------------------------------------------------------- /app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { ArrowLeft } from "@phosphor-icons/react" 5 | import Link from "next/link" 6 | import { useSearchParams } from "next/navigation" 7 | import { Suspense } from "react" 8 | 9 | export const dynamic = "force-dynamic" 10 | 11 | // Create a separate component that uses useSearchParams 12 | function AuthErrorContent() { 13 | const searchParams = useSearchParams() 14 | const message = 15 | searchParams.get("message") || "An error occurred during authentication." 16 | 17 | return ( 18 |
19 |
20 |

21 | Authentication Error 22 |

23 |
24 |

{message}

25 |
26 |
27 | 35 |
36 |
37 |
38 | ) 39 | } 40 | 41 | export default function AuthErrorPage() { 42 | return ( 43 |
44 | {/* Header */} 45 |
46 | 50 | 51 | 52 | Back to Chat 53 | 54 | 55 |
56 | 57 |
58 | Loading...
}> 59 | 60 | 61 | 62 | 63 |
64 |

65 | Need help? {/* @todo */} 66 | 67 | Contact Support 68 | 69 |

70 |
71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /app/auth/login/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { toast } from "@/components/ui/toast" 4 | import { isSupabaseEnabled } from "@/lib/supabase/config" 5 | import { createClient } from "@/lib/supabase/server" 6 | import { revalidatePath } from "next/cache" 7 | import { redirect } from "next/navigation" 8 | 9 | export async function signOut() { 10 | if (!isSupabaseEnabled) { 11 | toast({ 12 | title: "Sign out is not supported in this deployment", 13 | status: "info", 14 | }) 15 | return 16 | } 17 | 18 | const supabase = await createClient() 19 | 20 | if (!supabase) { 21 | toast({ 22 | title: "Sign out is not supported in this deployment", 23 | status: "info", 24 | }) 25 | return 26 | } 27 | 28 | await supabase.auth.signOut() 29 | revalidatePath("/", "layout") 30 | redirect("/auth/login") 31 | } 32 | -------------------------------------------------------------------------------- /app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | import { isSupabaseEnabled } from "@/lib/supabase/config" 2 | import { notFound } from "next/navigation" 3 | import LoginPage from "./login-page" 4 | 5 | export default function AuthPage() { 6 | if (!isSupabaseEnabled) { 7 | return notFound() 8 | } 9 | 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /app/c/[chatId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/app/components/chat/chat" 2 | import { LayoutApp } from "@/app/components/layout/layout-app" 3 | import { MessagesProvider } from "@/lib/chat-store/messages/provider" 4 | import { isSupabaseEnabled } from "@/lib/supabase/config" 5 | import { createClient } from "@/lib/supabase/server" 6 | import { redirect } from "next/navigation" 7 | 8 | export default async function Page() { 9 | if (isSupabaseEnabled) { 10 | const supabase = await createClient() 11 | if (supabase) { 12 | const { data: userData, error: userError } = await supabase.auth.getUser() 13 | if (userError || !userData?.user) { 14 | redirect("/") 15 | } 16 | } 17 | } 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/components/agents/agent-featured-section.tsx: -------------------------------------------------------------------------------- 1 | import { Agent } from "@/app/types/agent" 2 | import React from "react" 3 | import { DialogAgent } from "./dialog-agent" 4 | 5 | type AgentFeaturedSectionProps = { 6 | agents: Agent[] 7 | handleAgentClick: (agentId: string | null) => void 8 | openAgentId: string | null 9 | setOpenAgentId: (agentId: string | null) => void 10 | moreAgents: Agent[] 11 | } 12 | 13 | export function AgentFeaturedSection({ 14 | agents, 15 | moreAgents, 16 | handleAgentClick, 17 | openAgentId, 18 | setOpenAgentId, 19 | }: AgentFeaturedSectionProps) { 20 | if (!agents || agents.length === 0) { 21 | return null 22 | } 23 | 24 | return ( 25 |
26 |

Featured

27 |
28 | {agents.map((agent) => ( 29 | setOpenAgentId(open ? agent.id : null)} 42 | randomAgents={moreAgents} 43 | /> 44 | ))} 45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/components/agents/agents-page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Agent } from "@/app/types/agent" 4 | import { Button } from "@/components/ui/button" 5 | import { useMemo, useState } from "react" 6 | import { AgentFeaturedSection } from "./agent-featured-section" 7 | import { DialogCreateAgentTrigger } from "./dialog-create-agent/dialog-trigger-create-agent" 8 | import { UserAgentsSection } from "./user-agent-section" 9 | 10 | type AgentsPageProps = { 11 | curatedAgents: Agent[] 12 | userAgents: Agent[] | null 13 | userId: string | null 14 | } 15 | 16 | export function AgentsPage({ 17 | curatedAgents, 18 | userAgents, 19 | userId, 20 | }: AgentsPageProps) { 21 | const [openAgentId, setOpenAgentId] = useState(null) 22 | 23 | const randomAgents = useMemo(() => { 24 | return curatedAgents 25 | .filter((agent) => agent.id !== openAgentId) 26 | .sort(() => Math.random() - 0.5) 27 | .slice(0, 4) 28 | }, [curatedAgents, openAgentId]) 29 | 30 | const handleAgentClick = (agentId: string | null) => { 31 | setOpenAgentId(agentId) 32 | } 33 | 34 | return ( 35 |
36 |
37 |
38 |

39 | Agents (experimental) 40 |

41 |
42 | Your every day AI assistant 43 |
44 |

45 | a growing set of personal AI agents, built for ideas, writing, and 46 | product work. 47 |

48 | 51 | Create an agent 52 | 53 | } 54 | /> 55 |
56 | 57 | 64 | 72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /app/components/chat-input/button-search.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { Popover, PopoverTrigger } from "@/components/ui/popover" 3 | import { cn } from "@/lib/utils" 4 | import { GlobeIcon } from "@phosphor-icons/react" 5 | import React from "react" 6 | import { PopoverContentAuth } from "./popover-content-auth" 7 | 8 | type ButtonSearchProps = { 9 | isSelected?: boolean 10 | onToggle?: (isSelected: boolean) => void 11 | isAuthenticated: boolean 12 | } 13 | 14 | export function ButtonSearch({ 15 | isSelected = false, 16 | onToggle, 17 | isAuthenticated, 18 | }: ButtonSearchProps) { 19 | const handleClick = () => { 20 | const newState = !isSelected 21 | onToggle?.(newState) 22 | } 23 | 24 | if (!isAuthenticated) { 25 | return ( 26 | 27 | 28 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | return ( 42 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/components/chat-input/file-list.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "motion/react" 2 | import { FileItem } from "./file-items" 3 | 4 | type FileListProps = { 5 | files: File[] 6 | onFileRemove: (file: File) => void 7 | } 8 | 9 | const TRANSITION = { 10 | type: "spring", 11 | duration: 0.2, 12 | bounce: 0, 13 | } 14 | 15 | export function FileList({ files, onFileRemove }: FileListProps) { 16 | return ( 17 | 18 | {files.length > 0 && ( 19 | 27 |
28 | 29 | {files.map((file) => ( 30 | 38 | 43 | 44 | ))} 45 | 46 |
47 |
48 | )} 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/components/chat-input/use-search-agent.ts: -------------------------------------------------------------------------------- 1 | import { useAgent } from "@/lib/agent-store/provider" 2 | import { localAgents } from "@/lib/agents/local-agents" 3 | import { useCallback, useEffect, useState } from "react" 4 | 5 | const SEARCH_AGENT_ID = "search" 6 | 7 | export function useSearchAgent() { 8 | const [isSearchEnabled, setIsSearchEnabled] = useState(false) 9 | const { currentAgent } = useAgent() 10 | 11 | // Check if current agent is the search agent 12 | const isSearchAgentActive = currentAgent?.id === SEARCH_AGENT_ID 13 | 14 | // Sync search state with current agent 15 | useEffect(() => { 16 | setIsSearchEnabled(isSearchAgentActive) 17 | }, [isSearchAgentActive]) 18 | 19 | const toggleSearch = useCallback((enabled: boolean) => { 20 | setIsSearchEnabled(enabled) 21 | }, []) 22 | 23 | // Return search agent ID when search is enabled, null otherwise 24 | const getActiveAgentId = useCallback(() => { 25 | return isSearchEnabled ? SEARCH_AGENT_ID : currentAgent?.id || null 26 | }, [isSearchEnabled, currentAgent?.id]) 27 | 28 | // Check if search agent is available 29 | const isSearchAgentAvailable = SEARCH_AGENT_ID in localAgents 30 | 31 | return { 32 | isSearchEnabled, 33 | toggleSearch, 34 | getActiveAgentId, 35 | isSearchAgentAvailable, 36 | isSearchAgentActive, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/components/chat/get-sources.ts: -------------------------------------------------------------------------------- 1 | import type { Message as MessageAISDK } from "@ai-sdk/react" 2 | 3 | export function getSources(parts: MessageAISDK["parts"]) { 4 | const sources = parts 5 | ?.filter( 6 | (part) => part.type === "source" || part.type === "tool-invocation" 7 | ) 8 | .map((part) => { 9 | if (part.type === "source") { 10 | return part.source 11 | } 12 | 13 | if ( 14 | part.type === "tool-invocation" && 15 | part.toolInvocation.state === "result" 16 | ) { 17 | const result = part.toolInvocation.result 18 | 19 | if ( 20 | part.toolInvocation.toolName === "summarizeSources" && 21 | result?.result?.[0]?.citations 22 | ) { 23 | return result.result.flatMap((item: { citations?: unknown[] }) => item.citations || []) 24 | } 25 | 26 | return Array.isArray(result) ? result.flat() : result 27 | } 28 | 29 | return null 30 | }) 31 | .filter(Boolean) 32 | .flat() 33 | 34 | const validSources = 35 | sources?.filter( 36 | (source) => 37 | source && typeof source === "object" && source.url && source.url !== "" 38 | ) || [] 39 | 40 | return validSources 41 | } 42 | -------------------------------------------------------------------------------- /app/components/chat/link-markdown.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | 3 | export function LinkMarkdown({ 4 | href, 5 | children, 6 | ...props 7 | }: React.ComponentProps<"a">) { 8 | if (!href) return {children} 9 | 10 | // Check if href is a valid URL 11 | let domain = "" 12 | try { 13 | const url = new URL(href) 14 | domain = url.hostname 15 | } catch { 16 | // If href is not a valid URL (likely a relative path) 17 | domain = href.split("/").pop() || href 18 | } 19 | 20 | return ( 21 | 27 | favicon 34 | 35 | {domain.replace("www.", "")} 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/components/chat/message.tsx: -------------------------------------------------------------------------------- 1 | import { Message as MessageType } from "@ai-sdk/react" 2 | import React, { useState } from "react" 3 | import { MessageAssistant } from "./message-assistant" 4 | import { MessageUser } from "./message-user" 5 | 6 | type MessageProps = { 7 | variant: MessageType["role"] 8 | children: string 9 | id: string 10 | attachments?: MessageType["experimental_attachments"] 11 | isLast?: boolean 12 | onDelete: (id: string) => void 13 | onEdit: (id: string, newText: string) => void 14 | onReload: () => void 15 | hasScrollAnchor?: boolean 16 | parts?: MessageType["parts"] 17 | status?: "streaming" | "ready" | "submitted" | "error" 18 | } 19 | 20 | export function Message({ 21 | variant, 22 | children, 23 | id, 24 | attachments, 25 | isLast, 26 | onDelete, 27 | onEdit, 28 | onReload, 29 | hasScrollAnchor, 30 | parts, 31 | status, 32 | }: MessageProps) { 33 | const [copied, setCopied] = useState(false) 34 | 35 | const copyToClipboard = () => { 36 | navigator.clipboard.writeText(children) 37 | setCopied(true) 38 | setTimeout(() => setCopied(false), 500) 39 | } 40 | 41 | if (variant === "user") { 42 | return ( 43 | 53 | {children} 54 | 55 | ) 56 | } 57 | 58 | if (variant === "assistant") { 59 | return ( 60 | 69 | {children} 70 | 71 | ) 72 | } 73 | 74 | return null 75 | } 76 | -------------------------------------------------------------------------------- /app/components/chat/reasoning.tsx: -------------------------------------------------------------------------------- 1 | import { Markdown } from "@/components/prompt-kit/markdown" 2 | import { cn } from "@/lib/utils" 3 | import { CaretDown } from "@phosphor-icons/react" 4 | import { AnimatePresence, motion } from "framer-motion" 5 | import { useState } from "react" 6 | 7 | type ReasoningProps = { 8 | reasoning: string 9 | } 10 | 11 | const TRANSITION = { 12 | type: "spring", 13 | duration: 0.2, 14 | bounce: 0, 15 | } 16 | 17 | export function Reasoning({ reasoning }: ReasoningProps) { 18 | const [isExpanded, setIsExpanded] = useState(true) 19 | 20 | return ( 21 |
22 | 35 | 36 | 37 | {isExpanded && ( 38 | 45 |
46 | {reasoning} 47 |
48 |
49 | )} 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/components/chat/search-images.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { addUTM, getFavicon, getSiteName } from "./utils" 3 | 4 | type ImageResult = { 5 | title: string 6 | imageUrl: string 7 | sourceUrl: string 8 | } 9 | 10 | export function SearchImages({ results }: { results: ImageResult[] }) { 11 | const [hiddenIndexes, setHiddenIndexes] = useState>(new Set()) 12 | 13 | const handleError = (index: number) => { 14 | setHiddenIndexes((prev) => new Set(prev).add(index)) 15 | } 16 | 17 | if (!results?.length) return null 18 | 19 | return ( 20 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/components/chat/use-chat-handlers.ts: -------------------------------------------------------------------------------- 1 | import { useChatDraft } from "@/app/hooks/use-chat-draft" 2 | import { UserProfile } from "@/app/types/user" 3 | import { toast } from "@/components/ui/toast" 4 | import { Message } from "@ai-sdk/react" 5 | import { useCallback } from "react" 6 | 7 | type UseChatHandlersProps = { 8 | messages: Message[] 9 | setMessages: ( 10 | messages: Message[] | ((messages: Message[]) => Message[]) 11 | ) => void 12 | setInput: (input: string) => void 13 | setSelectedModel: (model: string) => void 14 | selectedModel: string 15 | chatId: string | null 16 | updateChatModel: (chatId: string, model: string) => Promise 17 | user: UserProfile | null 18 | } 19 | 20 | export function useChatHandlers({ 21 | messages, 22 | setMessages, 23 | setInput, 24 | setSelectedModel, 25 | selectedModel, 26 | chatId, 27 | updateChatModel, 28 | user, 29 | }: UseChatHandlersProps) { 30 | const { setDraftValue } = useChatDraft(chatId) 31 | 32 | const handleInputChange = useCallback( 33 | (value: string) => { 34 | setInput(value) 35 | setDraftValue(value) 36 | }, 37 | [setInput, setDraftValue] 38 | ) 39 | 40 | const handleModelChange = useCallback( 41 | async (model: string) => { 42 | if (!user?.id) { 43 | return 44 | } 45 | 46 | if (!chatId && user?.id) { 47 | setSelectedModel(model) 48 | return 49 | } 50 | 51 | const oldModel = selectedModel 52 | 53 | setSelectedModel(model) 54 | 55 | try { 56 | await updateChatModel(chatId!, model) 57 | } catch (err) { 58 | console.error("Failed to update chat model:", err) 59 | setSelectedModel(oldModel) 60 | toast({ 61 | title: "Failed to update chat model", 62 | status: "error", 63 | }) 64 | } 65 | }, 66 | [chatId, selectedModel, setSelectedModel, updateChatModel, user?.id] 67 | ) 68 | 69 | const handleDelete = useCallback( 70 | (id: string) => { 71 | setMessages(messages.filter((message) => message.id !== id)) 72 | }, 73 | [messages, setMessages] 74 | ) 75 | 76 | const handleEdit = useCallback( 77 | (id: string, newText: string) => { 78 | setMessages( 79 | messages.map((message) => 80 | message.id === id ? { ...message, content: newText } : message 81 | ) 82 | ) 83 | }, 84 | [messages, setMessages] 85 | ) 86 | 87 | return { 88 | handleInputChange, 89 | handleModelChange, 90 | handleDelete, 91 | handleEdit, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/components/chat/use-file-upload.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "@/components/ui/toast" 2 | import { 3 | Attachment, 4 | checkFileUploadLimit, 5 | processFiles, 6 | } from "@/lib/file-handling" 7 | import { useCallback, useState } from "react" 8 | 9 | export const useFileUpload = () => { 10 | const [files, setFiles] = useState([]) 11 | 12 | const handleFileUploads = async ( 13 | uid: string, 14 | chatId: string 15 | ): Promise => { 16 | if (files.length === 0) return [] 17 | 18 | try { 19 | await checkFileUploadLimit(uid) 20 | } catch (err: unknown) { 21 | const error = err as { code?: string; message?: string } 22 | if (error.code === "DAILY_FILE_LIMIT_REACHED") { 23 | toast({ title: error.message || "Daily file limit reached", status: "error" }) 24 | return null 25 | } 26 | } 27 | 28 | try { 29 | const processed = await processFiles(files, chatId, uid) 30 | setFiles([]) 31 | return processed 32 | } catch { 33 | toast({ title: "Failed to process files", status: "error" }) 34 | return null 35 | } 36 | } 37 | 38 | const createOptimisticAttachments = (files: File[]) => { 39 | return files.map((file) => ({ 40 | name: file.name, 41 | contentType: file.type, 42 | url: file.type.startsWith("image/") ? URL.createObjectURL(file) : "", 43 | })) 44 | } 45 | 46 | const cleanupOptimisticAttachments = (attachments?: Array<{ url?: string }>) => { 47 | if (!attachments) return 48 | attachments.forEach((attachment) => { 49 | if (attachment.url?.startsWith("blob:")) { 50 | URL.revokeObjectURL(attachment.url) 51 | } 52 | }) 53 | } 54 | 55 | const handleFileUpload = useCallback((newFiles: File[]) => { 56 | setFiles((prev) => [...prev, ...newFiles]) 57 | }, []) 58 | 59 | const handleFileRemove = useCallback((file: File) => { 60 | setFiles((prev) => prev.filter((f) => f !== file)) 61 | }, []) 62 | 63 | return { 64 | files, 65 | setFiles, 66 | handleFileUploads, 67 | createOptimisticAttachments, 68 | cleanupOptimisticAttachments, 69 | handleFileUpload, 70 | handleFileRemove, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/components/chat/utils.ts: -------------------------------------------------------------------------------- 1 | export const addUTM = (url: string) => { 2 | try { 3 | // Check if the URL is valid 4 | const u = new URL(url) 5 | // Ensure it's using HTTP or HTTPS protocol 6 | if (!["http:", "https:"].includes(u.protocol)) { 7 | return url // Return original URL for non-http(s) URLs 8 | } 9 | 10 | u.searchParams.set("utm_source", "zola.chat") 11 | u.searchParams.set("utm_medium", "research") 12 | return u.toString() 13 | } catch { 14 | // If URL is invalid, return the original URL without modification 15 | return url 16 | } 17 | } 18 | 19 | export const getFavicon = (url: string | null) => { 20 | if (!url) return null 21 | 22 | try { 23 | // Check if the URL is valid 24 | const urlObj = new URL(url) 25 | // Ensure it's using HTTP or HTTPS protocol 26 | if (!["http:", "https:"].includes(urlObj.protocol)) { 27 | return null 28 | } 29 | 30 | const domain = urlObj.hostname 31 | return `https://www.google.com/s2/favicons?domain=${domain}&sz=32` 32 | } catch { 33 | // No need to log errors for invalid URLs 34 | return null 35 | } 36 | } 37 | 38 | export const formatUrl = (url: string) => { 39 | try { 40 | return url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "") 41 | } catch { 42 | return url 43 | } 44 | } 45 | 46 | export const getSiteName = (url: string) => { 47 | try { 48 | const urlObj = new URL(url) 49 | return urlObj.hostname.replace(/^www\./, "") 50 | } catch { 51 | return url 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/components/header-go-back.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft } from "@phosphor-icons/react" 2 | import Link from "next/link" 3 | 4 | export function HeaderGoBack({ href = "/" }: { href?: string }) { 5 | return ( 6 |
7 | 12 | 13 | 14 | Back to Chat 15 | 16 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/components/layout/agent-link.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipTrigger, 5 | } from "@/components/ui/tooltip" 6 | import { isSupabaseEnabled } from "@/lib/supabase/config" 7 | import { UsersThree } from "@phosphor-icons/react" 8 | import Link from "next/link" 9 | 10 | export function AgentLink() { 11 | if (!isSupabaseEnabled) { 12 | return null 13 | } 14 | 15 | return ( 16 | 17 | 18 | 24 | 25 | 26 | 27 | Agents 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/components/layout/app-info/app-info-content.tsx: -------------------------------------------------------------------------------- 1 | import { APP_DESCRIPTION } from "@/lib/config" 2 | import React from "react" 3 | 4 | export function AppInfoContent() { 5 | return ( 6 |
7 |

8 | {APP_DESCRIPTION} Built with Vercel's AI SDK, Supabase, and prompt-kit 9 | components. 10 |

11 |

12 | The code is available on{" "} 13 | 19 | GitHub 20 | 21 | . 22 |

23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/layout/button-new-chat.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip" 8 | import { NotePencil } from "@phosphor-icons/react/dist/ssr" 9 | import Link from "next/link" 10 | import { usePathname, useRouter } from "next/navigation" 11 | import { useEffect } from "react" 12 | 13 | export function ButtonNewChat() { 14 | const pathname = usePathname() 15 | const router = useRouter() 16 | 17 | useEffect(() => { 18 | const handleKeyDown = (e: KeyboardEvent) => { 19 | if ((e.key === "u" || e.key === "U") && e.metaKey && e.shiftKey) { 20 | e.preventDefault() 21 | router.push("/") 22 | } 23 | } 24 | 25 | window.addEventListener("keydown", handleKeyDown) 26 | return () => window.removeEventListener("keydown", handleKeyDown) 27 | }, [router]) 28 | 29 | if (pathname === "/") { 30 | return null 31 | } 32 | 33 | return ( 34 | 35 | 36 | 42 | 43 | 44 | 45 | New Chat ⌘⇧U 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/components/layout/feedback/feedback-trigger.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useBreakpoint } from "@/app/hooks/use-breakpoint" 4 | import { useUser } from "@/lib/user-store/provider" 5 | import { FeedbackForm } from "@/components/common/feedback-form" 6 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog" 7 | import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer" 8 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu" 9 | import { isSupabaseEnabled } from "@/lib/supabase/config" 10 | import { Question } from "@phosphor-icons/react" 11 | import { useState } from "react" 12 | 13 | export function FeedbackTrigger() { 14 | const { user } = useUser() 15 | const isMobile = useBreakpoint(768) 16 | const [isOpen, setIsOpen] = useState(false) 17 | 18 | if (!isSupabaseEnabled) { 19 | return null 20 | } 21 | 22 | const handleClose = () => { 23 | setIsOpen(false) 24 | } 25 | 26 | const trigger = ( 27 | e.preventDefault()}> 28 | 29 | Feedback 30 | 31 | ) 32 | 33 | if (isMobile) { 34 | return ( 35 | <> 36 | 37 | {trigger} 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | return ( 47 | <> 48 | 49 | {trigger} 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/components/layout/header-agent.tsx: -------------------------------------------------------------------------------- 1 | import { useBreakpoint } from "@/app/hooks/use-breakpoint" 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip" 9 | import { Info } from "@phosphor-icons/react" 10 | import { AnimatePresence, motion } from "motion/react" 11 | import { AgentHeader } from "./header" 12 | 13 | type HeaderAgentProps = { 14 | agent?: AgentHeader | null 15 | } 16 | 17 | export function HeaderAgent({ agent }: HeaderAgentProps) { 18 | const isMobile = useBreakpoint(768) 19 | const initials = agent?.name 20 | .split(" ") 21 | .map((n) => n[0]) 22 | .join("") 23 | 24 | return ( 25 | 26 | {agent && ( 27 | 38 | 39 | 44 | {initials} 45 | 46 | 47 |
48 |

{agent.name}

49 | {!isMobile && agent.description && ( 50 | 51 | 52 | 53 | 57 | 58 | 59 |

{agent.description}

60 |
61 |
62 |
63 | )} 64 |
65 |
66 | )} 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /app/components/layout/header-sidebar-trigger.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useSidebar } from "@/components/ui/sidebar" 4 | import { 5 | Tooltip, 6 | TooltipContent, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip" 9 | import { cn } from "@/lib/utils" 10 | import { SidebarSimple } from "@phosphor-icons/react" 11 | 12 | type HeaderSidebarTriggerProps = React.HTMLAttributes 13 | 14 | export function HeaderSidebarTrigger({ 15 | className, 16 | ...props 17 | }: HeaderSidebarTriggerProps) { 18 | const { toggleSidebar, open, isMobile } = useSidebar() 19 | 20 | return ( 21 | 22 | 23 | 38 | 39 | {open ? "Close sidebar" : "Open sidebar"} 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/components/layout/layout-app.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Header } from "@/app/components/layout/header" 4 | import { AppSidebar } from "@/app/components/layout/sidebar/app-sidebar" 5 | import { useUserPreferences } from "@/lib/user-preference-store/provider" 6 | 7 | export function LayoutApp({ children }: { children: React.ReactNode }) { 8 | const { preferences } = useUserPreferences() 9 | const hasSidebar = preferences.layout === "sidebar" 10 | 11 | return ( 12 |
13 | {hasSidebar && } 14 |
15 |
16 | {children} 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/components/layout/settings/appearance/index.tsx: -------------------------------------------------------------------------------- 1 | export { ThemeSelection } from "./theme-selection" 2 | export { LayoutSettings } from "./layout-settings" 3 | export { InteractionPreferences } from "./interaction-preferences" 4 | -------------------------------------------------------------------------------- /app/components/layout/settings/appearance/interaction-preferences.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Switch } from "@/components/ui/switch" 4 | import { useUserPreferences } from "@/lib/user-preference-store/provider" 5 | 6 | export function InteractionPreferences() { 7 | const { preferences, setPromptSuggestions, setShowToolInvocations } = 8 | useUserPreferences() 9 | 10 | return ( 11 |
12 | {/* Prompt Suggestions */} 13 |
14 |
15 |
16 |

Prompt suggestions

17 |

18 | Show suggested prompts when starting a new conversation 19 |

20 |
21 | 25 |
26 |
27 | 28 | {/* Tool Invocations */} 29 |
30 |
31 |
32 |

Tool invocations

33 |

34 | Show tool execution details in conversations 35 |

36 |
37 | 41 |
42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/components/layout/settings/appearance/theme-selection.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { useState } from "react" 5 | 6 | export function ThemeSelection() { 7 | const { theme, setTheme } = useTheme() 8 | const [selectedTheme, setSelectedTheme] = useState(theme || "system") 9 | 10 | const themes = [ 11 | { id: "system", name: "System", colors: ["#ffffff", "#1a1a1a"] }, 12 | { id: "light", name: "Light", colors: ["#ffffff"] }, 13 | { id: "dark", name: "Dark", colors: ["#1a1a1a"] }, 14 | ] 15 | 16 | return ( 17 |
18 |

Theme

19 |
20 | {themes.map((theme) => ( 21 | 45 | ))} 46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /app/components/layout/settings/connections/connections-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { PlugsConnected } from "@phosphor-icons/react" 2 | 3 | export function ConnectionsPlaceholder() { 4 | return ( 5 |
6 | 7 |

No developer tools available

8 |

9 | Third-party service connections will appear here in development mode. 10 |

11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/components/layout/settings/connections/index.tsx: -------------------------------------------------------------------------------- 1 | export { DeveloperTools } from "./developer-tools" 2 | export { ProviderSettings } from "./provider-settings" 3 | -------------------------------------------------------------------------------- /app/components/layout/settings/general/account-management.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { toast } from "@/components/ui/toast" 5 | import { useChats } from "@/lib/chat-store/chats/provider" 6 | import { useMessages } from "@/lib/chat-store/messages/provider" 7 | import { clearAllIndexedDBStores } from "@/lib/chat-store/persist" 8 | import { useUser } from "@/lib/user-store/provider" 9 | import { SignOut } from "@phosphor-icons/react" 10 | import { useRouter } from "next/navigation" 11 | 12 | export function AccountManagement() { 13 | const { signOut } = useUser() 14 | const { resetChats } = useChats() 15 | const { resetMessages } = useMessages() 16 | const router = useRouter() 17 | 18 | const handleSignOut = async () => { 19 | try { 20 | await resetMessages() 21 | await resetChats() 22 | await signOut() 23 | await clearAllIndexedDBStores() 24 | router.push("/") 25 | } catch (e) { 26 | console.error("Sign out failed:", e) 27 | toast({ title: "Failed to sign out", status: "error" }) 28 | } 29 | } 30 | 31 | return ( 32 |
33 |
34 |

Account

35 |

Log out on this device

36 |
37 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/components/layout/settings/general/index.tsx: -------------------------------------------------------------------------------- 1 | export { UserProfile } from "./user-profile" 2 | export { ModelPreferences } from "./model-preferences" 3 | export { AccountManagement } from "./account-management" 4 | export { SystemPromptSection } from "./system-prompt" 5 | -------------------------------------------------------------------------------- /app/components/layout/settings/general/model-preferences.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ModelSelector } from "@/components/common/model-selector/base" 4 | import { MODEL_DEFAULT } from "@/lib/config" 5 | import { useUser } from "@/lib/user-store/provider" 6 | import { useEffect, useState } from "react" 7 | import { SystemPromptSection } from "./system-prompt" 8 | 9 | export function ModelPreferences() { 10 | const { user, updateUser } = useUser() 11 | const [selectedModelId, setSelectedModelId] = useState( 12 | user?.preferred_model || MODEL_DEFAULT 13 | ) 14 | 15 | useEffect(() => { 16 | if (user?.preferred_model) { 17 | setSelectedModelId(user.preferred_model) 18 | } 19 | }, [user?.preferred_model]) 20 | 21 | const handleModelSelection = async (value: string) => { 22 | setSelectedModelId(value) 23 | await updateUser({ preferred_model: value }) 24 | } 25 | 26 | return ( 27 |
28 |
29 |

Preferred model

30 |
31 | 36 |
37 |

38 | This model will be used by default for new conversations. 39 |

40 |
41 | 42 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/components/layout/settings/general/user-profile.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 4 | import { useUser } from "@/lib/user-store/provider" 5 | import { User } from "@phosphor-icons/react" 6 | 7 | export function UserProfile() { 8 | const { user } = useUser() 9 | 10 | if (!user) return null 11 | 12 | return ( 13 |
14 |

Profile

15 |
16 |
17 | {user?.profile_image ? ( 18 | 19 | 20 | {user?.display_name?.charAt(0)} 21 | 22 | ) : ( 23 | 24 | )} 25 |
26 |
27 |

{user?.display_name}

28 |

{user?.email}

29 |
30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/components/layout/settings/settings-trigger.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useBreakpoint } from "@/app/hooks/use-breakpoint" 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog" 11 | import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer" 12 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu" 13 | import { User } from "@phosphor-icons/react" 14 | import type React from "react" 15 | import { useState } from "react" 16 | import { SettingsContent } from "./settings-content" 17 | 18 | export function SettingsTrigger() { 19 | const [open, setOpen] = useState(false) 20 | const isMobile = useBreakpoint(768) 21 | 22 | const trigger = ( 23 | e.preventDefault()}> 24 | 25 | Settings 26 | 27 | ) 28 | 29 | if (isMobile) { 30 | return ( 31 | 32 | {trigger} 33 | 34 | setOpen(false)} /> 35 | 36 | 37 | ) 38 | } 39 | 40 | return ( 41 | 42 | {trigger} 43 | 44 | 45 | Settings 46 | 47 | setOpen(false)} /> 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/components/layout/sidebar/dialog-delete-chat.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | } from "@/components/ui/alert-dialog" 13 | 14 | type DialogDeleteChatProps = { 15 | isOpen: boolean 16 | setIsOpen: (isOpen: boolean) => void 17 | chatTitle: string 18 | onConfirmDelete: () => Promise 19 | } 20 | 21 | export function DialogDeleteChat({ 22 | isOpen, 23 | setIsOpen, 24 | chatTitle, 25 | onConfirmDelete, 26 | }: DialogDeleteChatProps) { 27 | return ( 28 | 29 | 30 | 31 | Delete chat? 32 | 33 | This will delete "{chatTitle}" 34 | 35 | 36 | 37 | Cancel 38 | { 40 | setIsOpen(false) 41 | await onConfirmDelete() 42 | }} 43 | > 44 | Delete 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/components/layout/sidebar/sidebar-list.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/lib/chat-store/types" 2 | import { SidebarItem } from "./sidebar-item" 3 | 4 | type SidebarListProps = { 5 | title: string 6 | items: Chat[] 7 | currentChatId: string 8 | } 9 | 10 | export function SidebarList({ title, items, currentChatId }: SidebarListProps) { 11 | return ( 12 |
13 |

14 | {title} 15 |

16 |
17 | {items.map((chat) => ( 18 | 23 | ))} 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/components/layout/user-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuSeparator, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu" 11 | import { 12 | Tooltip, 13 | TooltipContent, 14 | TooltipTrigger, 15 | } from "@/components/ui/tooltip" 16 | import { useUser } from "@/lib/user-store/provider" 17 | import { AppInfoTrigger } from "./app-info/app-info-trigger" 18 | import { FeedbackTrigger } from "./feedback/feedback-trigger" 19 | import { SettingsTrigger } from "./settings/settings-trigger" 20 | 21 | export function UserMenu() { 22 | const { user } = useUser() 23 | 24 | if (!user) return null 25 | 26 | return ( 27 | // fix shadcn/ui / radix bug when dialog into dropdown menu 28 | 29 | 30 | 31 | 32 | 33 | 34 | {user?.display_name?.charAt(0)} 35 | 36 | 37 | 38 | Profile 39 | 40 | e.preventDefault()} 45 | > 46 | 47 | {user?.display_name} 48 | 49 | {user?.email} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/components/suggestions/prompt-system.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AnimatePresence } from "motion/react" 4 | import React, { memo } from "react" 5 | import { Suggestions } from "../chat-input/suggestions" 6 | 7 | type PromptSystemProps = { 8 | onValueChange: (value: string) => void 9 | onSuggestion: (suggestion: string) => void 10 | value: string 11 | } 12 | 13 | export const PromptSystem = memo(function PromptSystem({ 14 | onValueChange, 15 | onSuggestion, 16 | value, 17 | }: PromptSystemProps) { 18 | return ( 19 | <> 20 |
21 | 22 | 27 | 28 |
29 | 30 | ) 31 | }) 32 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/app/favicon.ico -------------------------------------------------------------------------------- /app/hooks/use-breakpoint.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | export function useBreakpoint(breakpoint: number) { 4 | const [isBelowBreakpoint, setIsBelowBreakpoint] = React.useState< 5 | boolean | undefined 6 | >(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`) 10 | const onChange = () => { 11 | setIsBelowBreakpoint(window.innerWidth < breakpoint) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsBelowBreakpoint(window.innerWidth < breakpoint) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, [breakpoint]) 17 | 18 | return !!isBelowBreakpoint 19 | } 20 | -------------------------------------------------------------------------------- /app/hooks/use-chat-draft.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | /** 4 | * Hook to manage persistent chat drafts using localStorage 5 | * @param chatId - The current chat ID (null for new chat) 6 | * @returns Object containing draft value and setter 7 | */ 8 | export function useChatDraft(chatId: string | null) { 9 | // Key for storing drafts in localStorage 10 | const storageKey = chatId ? `chat-draft-${chatId}` : 'chat-draft-new' 11 | 12 | // Initialize state from localStorage 13 | const [draftValue, setDraftValue] = useState(() => { 14 | if (typeof window === 'undefined') return '' 15 | return localStorage.getItem(storageKey) || '' 16 | }) 17 | 18 | // Update localStorage when draft changes 19 | useEffect(() => { 20 | if (draftValue) { 21 | localStorage.setItem(storageKey, draftValue) 22 | } else { 23 | localStorage.removeItem(storageKey) 24 | } 25 | }, [draftValue, storageKey]) 26 | 27 | // Clear draft for the current chat 28 | const clearDraft = () => { 29 | setDraftValue('') 30 | localStorage.removeItem(storageKey) 31 | } 32 | 33 | return { 34 | draftValue, 35 | setDraftValue, 36 | clearDraft 37 | } 38 | } -------------------------------------------------------------------------------- /app/hooks/use-click-outside.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react" 2 | 3 | function useClickOutside( 4 | ref: RefObject, 5 | handler: (event: MouseEvent | TouchEvent) => void 6 | ): void { 7 | useEffect(() => { 8 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 9 | if (!ref || !ref.current || ref.current.contains(event.target as Node)) { 10 | return 11 | } 12 | 13 | handler(event) 14 | } 15 | 16 | document.addEventListener("mousedown", handleClickOutside) 17 | document.addEventListener("touchstart", handleClickOutside) 18 | 19 | return () => { 20 | document.removeEventListener("mousedown", handleClickOutside) 21 | document.removeEventListener("touchstart", handleClickOutside) 22 | } 23 | }, [ref, handler]) 24 | } 25 | 26 | export default useClickOutside 27 | -------------------------------------------------------------------------------- /app/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /app/layout-client.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { API_ROUTE_CSRF } from "@/lib/routes" 4 | import { useEffect } from "react" 5 | 6 | export function LayoutClient() { 7 | useEffect(() => { 8 | const init = async () => { 9 | fetch(API_ROUTE_CSRF) 10 | } 11 | 12 | init() 13 | }, []) 14 | return null 15 | } 16 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |
5 |

404 – Page not found

6 |

7 | Sorry, this page doesn’t exist. 8 |

9 |
10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /app/opengraph-image.alt: -------------------------------------------------------------------------------- 1 | Zola is a free, open-source AI chat app with multi-model support. 2 | -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/p/[chatId]/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import React from "react" 3 | 4 | export function Header() { 5 | return ( 6 |
7 |
8 |
9 | 10 | Zola 11 | 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/app/components/chat/chat" 2 | import { LayoutApp } from "@/app/components/layout/layout-app" 3 | import { MessagesProvider } from "@/lib/chat-store/messages/provider" 4 | 5 | export const dynamic = "force-dynamic" 6 | 7 | export default function Home() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/types/agent.ts: -------------------------------------------------------------------------------- 1 | import type { Tables } from "./database.types" 2 | 3 | export type Agent = Tables<"agents"> 4 | 5 | export type AgentSummary = Pick< 6 | Tables<"agents">, 7 | | "id" 8 | | "name" 9 | | "description" 10 | | "avatar_url" 11 | | "example_inputs" 12 | | "creator_id" 13 | | "slug" 14 | | "system_prompt" 15 | | "tools" 16 | | "mcp_config" 17 | > 18 | 19 | export type AgentsSuggestions = Pick< 20 | Tables<"agents">, 21 | "id" | "name" | "description" | "avatar_url" | "slug" 22 | > 23 | -------------------------------------------------------------------------------- /app/types/api.types.ts: -------------------------------------------------------------------------------- 1 | import type { Database, Json } from "@/app/types/database.types" 2 | import type { SupabaseClient } from "@supabase/supabase-js" 3 | import type { Attachment } from "@ai-sdk/ui-utils" 4 | 5 | export type SupabaseClientType = SupabaseClient 6 | 7 | export interface ContentPart { 8 | type: string 9 | text?: string 10 | toolCallId?: string 11 | toolName?: string 12 | args?: Json 13 | result?: Json 14 | toolInvocation?: { 15 | state: string 16 | step: number 17 | toolCallId: string 18 | toolName: string 19 | args?: Json 20 | result?: Json 21 | } 22 | reasoning?: string 23 | details?: Json[] 24 | } 25 | 26 | export interface Message { 27 | role: "user" | "assistant" | "system" | "data" | "tool" | "tool-call" 28 | content: string | null | ContentPart[] 29 | reasoning?: string 30 | } 31 | 32 | export interface ChatApiParams { 33 | userId: string 34 | model: string 35 | isAuthenticated: boolean 36 | } 37 | 38 | export interface LogUserMessageParams { 39 | supabase: SupabaseClientType 40 | userId: string 41 | chatId: string 42 | content: string 43 | attachments?: Attachment[] 44 | model: string 45 | isAuthenticated: boolean 46 | } 47 | 48 | export interface StoreAssistantMessageParams { 49 | supabase: SupabaseClientType 50 | chatId: string 51 | messages: Message[] 52 | } 53 | 54 | export interface ApiErrorResponse { 55 | error: string 56 | details?: string 57 | } 58 | 59 | export interface ApiSuccessResponse { 60 | success: true 61 | data?: T 62 | } 63 | 64 | export type ApiResponse = ApiSuccessResponse | ApiErrorResponse -------------------------------------------------------------------------------- /app/types/user.ts: -------------------------------------------------------------------------------- 1 | import type { Tables } from "@/app/types/database.types" 2 | 3 | export type UserProfile = { 4 | profile_image: string 5 | display_name: string 6 | } & Tables<"users"> 7 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/common/button-copy.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { TextMorph } from "../motion-primitives/text-morph" 5 | 6 | type ButtonCopyProps = { 7 | code: string 8 | } 9 | 10 | export function ButtonCopy({ code }: ButtonCopyProps) { 11 | const [hasCopyLabel, setHasCopyLabel] = useState(false) 12 | 13 | const onCopy = () => { 14 | navigator.clipboard.writeText(code) 15 | setHasCopyLabel(true) 16 | 17 | setTimeout(() => { 18 | setHasCopyLabel(false) 19 | }, 1000) 20 | } 21 | 22 | return ( 23 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/icons/anthropic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 19 | 20 | ) 21 | export default Icon 22 | -------------------------------------------------------------------------------- /components/icons/claude.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | export default Icon 27 | -------------------------------------------------------------------------------- /components/icons/deepseek.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | export default function DeepSeekIcon(props: SVGProps) { 5 | return ( 6 | 14 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/icons/gemini.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 14 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | export default Icon 39 | -------------------------------------------------------------------------------- /components/icons/google.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 17 | 21 | 25 | 29 | 30 | ) 31 | export default Icon 32 | -------------------------------------------------------------------------------- /components/icons/grok.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | export default Icon 29 | -------------------------------------------------------------------------------- /components/icons/mistral.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 14 | 18 | 22 | 23 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | export default Icon 40 | -------------------------------------------------------------------------------- /components/icons/ollama.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 17 | 23 | 29 | 30 | ) 31 | export default Icon -------------------------------------------------------------------------------- /components/icons/openai.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 13 | 19 | 20 | ) 21 | export default Icon 22 | -------------------------------------------------------------------------------- /components/icons/openrouter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | const Icon = (props: SVGProps) => ( 5 | 14 | 20 | 21 | ) 22 | 23 | export default Icon 24 | -------------------------------------------------------------------------------- /components/icons/x.tsx: -------------------------------------------------------------------------------- 1 | export default function XIcon(props: React.SVGProps) { 2 | return ( 3 | 12 | X 13 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/icons/xai.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { SVGProps } from "react" 3 | 4 | export default function XaiIcon(props: SVGProps) { 5 | return ( 6 | 15 | Grok 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/motion-primitives/progressive-blur.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { HTMLMotionProps, motion } from "motion/react" 5 | 6 | export const GRADIENT_ANGLES = { 7 | top: 0, 8 | right: 90, 9 | bottom: 180, 10 | left: 270, 11 | } 12 | 13 | export type ProgressiveBlurProps = { 14 | direction?: keyof typeof GRADIENT_ANGLES 15 | blurLayers?: number 16 | className?: string 17 | blurIntensity?: number 18 | } & HTMLMotionProps<"div"> 19 | 20 | export function ProgressiveBlur({ 21 | direction = "bottom", 22 | blurLayers = 8, 23 | className, 24 | blurIntensity = 0.25, 25 | ...props 26 | }: ProgressiveBlurProps) { 27 | const layers = Math.max(blurLayers, 2) 28 | const segmentSize = 1 / (blurLayers + 1) 29 | 30 | return ( 31 |
32 | {Array.from({ length: layers }).map((_, index) => { 33 | const angle = GRADIENT_ANGLES[direction] 34 | const gradientStops = [ 35 | index * segmentSize, 36 | (index + 1) * segmentSize, 37 | (index + 2) * segmentSize, 38 | (index + 3) * segmentSize, 39 | ].map( 40 | (pos, posIndex) => 41 | `rgba(255, 255, 255, ${posIndex === 1 || posIndex === 2 ? 1 : 0}) ${pos * 100}%` 42 | ) 43 | 44 | const gradient = `linear-gradient(${angle}deg, ${gradientStops.join( 45 | ", " 46 | )})` 47 | 48 | return ( 49 | 59 | ) 60 | })} 61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /components/motion-primitives/text-morph.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { AnimatePresence, motion, Transition, Variants } from "motion/react" 5 | import { useId, useMemo } from "react" 6 | 7 | export type TextMorphProps = { 8 | children: string 9 | as?: React.ElementType 10 | className?: string 11 | style?: React.CSSProperties 12 | variants?: Variants 13 | transition?: Transition 14 | } 15 | 16 | export function TextMorph({ 17 | children, 18 | as: Component = "p", 19 | className, 20 | style, 21 | variants, 22 | transition, 23 | }: TextMorphProps) { 24 | const uniqueId = useId() 25 | 26 | const characters = useMemo(() => { 27 | const charCounts: Record = {} 28 | 29 | return children.split("").map((char) => { 30 | const lowerChar = char.toLowerCase() 31 | charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1 32 | 33 | return { 34 | id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`, 35 | label: char === " " ? "\u00A0" : char, 36 | } 37 | }) 38 | }, [children, uniqueId]) 39 | 40 | const defaultVariants: Variants = { 41 | initial: { opacity: 0 }, 42 | animate: { opacity: 1 }, 43 | exit: { opacity: 0 }, 44 | } 45 | 46 | const defaultTransition: Transition = { 47 | type: "spring", 48 | stiffness: 280, 49 | damping: 18, 50 | mass: 0.3, 51 | } 52 | 53 | return ( 54 | 55 | 56 | {characters.map((character) => ( 57 | 70 | ))} 71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /components/motion-primitives/useClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | 3 | function useClickOutside( 4 | ref: RefObject, 5 | handler: (event: MouseEvent | TouchEvent) => void 6 | ): void { 7 | useEffect(() => { 8 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 9 | if (!ref || !ref.current || ref.current.contains(event.target as Node)) { 10 | return; 11 | } 12 | 13 | handler(event); 14 | }; 15 | 16 | document.addEventListener('mousedown', handleClickOutside); 17 | document.addEventListener('touchstart', handleClickOutside); 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', handleClickOutside); 21 | document.removeEventListener('touchstart', handleClickOutside); 22 | }; 23 | }, [ref, handler]); 24 | } 25 | 26 | export default useClickOutside; 27 | -------------------------------------------------------------------------------- /components/prompt-kit/chat-container.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { StickToBottom } from "use-stick-to-bottom" 5 | 6 | export type ChatContainerRootProps = { 7 | children: React.ReactNode 8 | className?: string 9 | } & React.HTMLAttributes 10 | 11 | export type ChatContainerContentProps = { 12 | children: React.ReactNode 13 | className?: string 14 | } & React.HTMLAttributes 15 | 16 | export type ChatContainerScrollAnchorProps = { 17 | className?: string 18 | ref?: React.RefObject 19 | } & React.HTMLAttributes 20 | 21 | function ChatContainerRoot({ 22 | children, 23 | className, 24 | ...props 25 | }: ChatContainerRootProps) { 26 | return ( 27 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | function ChatContainerContent({ 40 | children, 41 | className, 42 | ...props 43 | }: ChatContainerContentProps) { 44 | return ( 45 | 49 | {children} 50 | 51 | ) 52 | } 53 | 54 | function ChatContainerScrollAnchor({ 55 | className, 56 | ...props 57 | }: ChatContainerScrollAnchorProps) { 58 | return ( 59 |