├── .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 | [](https://zola.chat/?agent=github/ibelick/zola)
8 |
9 | 
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 | [](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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
21 | )
22 |
23 | export default Icon
24 |
--------------------------------------------------------------------------------
/components/icons/x.tsx:
--------------------------------------------------------------------------------
1 | export default function XIcon(props: React.SVGProps) {
2 | return (
3 |
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 |
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 |
68 | {character.label}
69 |
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 |
64 | )
65 | }
66 |
67 | export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor }
68 |
--------------------------------------------------------------------------------
/components/prompt-kit/code-block.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import { useTheme } from "next-themes"
5 | import React, { useEffect, useState } from "react"
6 | import { codeToHtml } from "shiki"
7 |
8 | export type CodeBlockProps = {
9 | children?: React.ReactNode
10 | className?: string
11 | } & React.HTMLProps
12 |
13 | function CodeBlock({ children, className, ...props }: CodeBlockProps) {
14 | return (
15 |
23 | {children}
24 |
25 | )
26 | }
27 |
28 | export type CodeBlockCodeProps = {
29 | code: string
30 | language?: string
31 | theme?: string
32 | className?: string
33 | } & React.HTMLProps
34 |
35 | function CodeBlockCode({
36 | code,
37 | language = "tsx",
38 | theme = "github-light",
39 | className,
40 | ...props
41 | }: CodeBlockCodeProps) {
42 | const { theme: appTheme } = useTheme()
43 | const [highlightedHtml, setHighlightedHtml] = useState(null)
44 |
45 | useEffect(() => {
46 | async function highlight() {
47 | const html = await codeToHtml(code, {
48 | lang: language,
49 | theme: appTheme === "dark" ? "github-dark" : "github-light",
50 | })
51 | setHighlightedHtml(html)
52 | }
53 | highlight()
54 | }, [code, language, theme, appTheme])
55 |
56 | const classNames = cn(
57 | "w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4 [&>pre]:!bg-background",
58 | className
59 | )
60 |
61 | // SSR fallback: render plain code if not hydrated yet
62 | return highlightedHtml ? (
63 |
68 | ) : (
69 |
70 |
71 | {code}
72 |
73 |
74 | )
75 | }
76 |
77 | export type CodeBlockGroupProps = React.HTMLAttributes
78 |
79 | function CodeBlockGroup({
80 | children,
81 | className,
82 | ...props
83 | }: CodeBlockGroupProps) {
84 | return (
85 |
89 | {children}
90 |
91 | )
92 | }
93 |
94 | export { CodeBlockGroup, CodeBlockCode, CodeBlock }
95 |
--------------------------------------------------------------------------------
/components/prompt-kit/loader.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { motion } from "framer-motion"
4 |
5 | // Style constants
6 | const DOT_SIZE = "size-2"
7 | const DOT_COLOR = "bg-primary/60"
8 | const DOT_SPACING = "gap-1"
9 |
10 | // Animation constants
11 | const ANIMATION_DURATION = 0.6
12 | const DELAY_DOT_1 = 0
13 | const DELAY_DOT_2 = 0.1
14 | const DELAY_DOT_3 = 0.2
15 |
16 | // Animation settings
17 | const ANIMATION = {
18 | y: ["0%", "-60%", "0%"],
19 | opacity: [1, 0.7, 1],
20 | }
21 |
22 | const TRANSITION = {
23 | duration: ANIMATION_DURATION,
24 | ease: "easeInOut",
25 | repeat: Number.POSITIVE_INFINITY,
26 | repeatType: "loop" as const,
27 | }
28 |
29 | export function Loader() {
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | function Dot({ delay }: { delay: number }) {
40 | return (
41 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/components/prompt-kit/scroll-button.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button, buttonVariants } from "@/components/ui/button"
4 | import { cn } from "@/lib/utils"
5 | import { type VariantProps } from "class-variance-authority"
6 | import { ChevronDown } from "lucide-react"
7 | import { useStickToBottomContext } from "use-stick-to-bottom"
8 |
9 | export type ScrollButtonProps = {
10 | className?: string
11 | variant?: VariantProps["variant"]
12 | size?: VariantProps["size"]
13 | } & React.ButtonHTMLAttributes
14 |
15 | function ScrollButton({
16 | className,
17 | variant = "outline",
18 | size = "sm",
19 | ...props
20 | }: ScrollButtonProps) {
21 | const { isAtBottom, scrollToBottom } = useStickToBottomContext()
22 |
23 | return (
24 |
39 | )
40 | }
41 |
42 | export { ScrollButton }
43 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 | import * as React from "react"
6 |
7 | function Avatar({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 | )
21 | }
22 |
23 | function AvatarImage({
24 | className,
25 | ...props
26 | }: React.ComponentProps) {
27 | return (
28 |
33 | )
34 | }
35 |
36 | function AvatarFallback({
37 | className,
38 | ...props
39 | }: React.ComponentProps) {
40 | return (
41 |
49 | )
50 | }
51 |
52 | export { Avatar, AvatarImage, AvatarFallback }
53 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import * as React from "react"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
15 | destructive:
16 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70",
17 | outline:
18 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19 | },
20 | },
21 | defaultVariants: {
22 | variant: "default",
23 | },
24 | }
25 | )
26 |
27 | function Badge({
28 | className,
29 | variant,
30 | asChild = false,
31 | ...props
32 | }: React.ComponentProps<"span"> &
33 | VariantProps & { asChild?: boolean }) {
34 | const Comp = asChild ? Slot : "span"
35 |
36 | return (
37 |
42 | )
43 | }
44 |
45 | export { Badge, badgeVariants }
46 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import * as React from "react"
5 |
6 | const buttonVariants = cva(
7 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
15 | outline:
16 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 dark:border-none",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
24 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
25 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
26 | icon: "size-9",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | function Button({
37 | className,
38 | variant,
39 | size,
40 | asChild = false,
41 | ...props
42 | }: React.ComponentProps<"button"> &
43 | VariantProps & {
44 | asChild?: boolean
45 | }) {
46 | const Comp = asChild ? Slot : "button"
47 |
48 | return (
49 |
54 | )
55 | }
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import * as React from "react"
3 |
4 | function Card({ className, ...props }: React.ComponentProps<"div">) {
5 | return (
6 |
14 | )
15 | }
16 |
17 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
18 | return (
19 |
27 | )
28 | }
29 |
30 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
31 | return (
32 |
37 | )
38 | }
39 |
40 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
41 | return (
42 |
47 | )
48 | }
49 |
50 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
51 | return (
52 |
60 | )
61 | }
62 |
63 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
64 | return (
65 |
70 | )
71 | }
72 |
73 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | )
81 | }
82 |
83 | export {
84 | Card,
85 | CardHeader,
86 | CardFooter,
87 | CardTitle,
88 | CardAction,
89 | CardDescription,
90 | CardContent,
91 | }
92 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { CheckIcon } from "lucide-react"
6 | import * as React from "react"
7 |
8 | function Checkbox({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 |
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | export { Checkbox }
32 |
--------------------------------------------------------------------------------
/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
5 | import * as React from "react"
6 |
7 | function HoverCard({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function HoverCardTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return (
17 |
18 | )
19 | }
20 |
21 | function HoverCardContent({
22 | className,
23 | align = "center",
24 | sideOffset = 4,
25 | ...props
26 | }: React.ComponentProps) {
27 | return (
28 |
29 |
39 |
40 | )
41 | }
42 |
43 | export { HoverCard, HoverCardTrigger, HoverCardContent }
44 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import * as React from "react"
3 |
4 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
5 | return (
6 |
17 | )
18 | }
19 |
20 | export { Input }
21 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 | import * as React from "react"
6 |
7 | function Popover({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function PopoverTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function PopoverContent({
20 | className,
21 | align = "center",
22 | sideOffset = 4,
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
27 |
37 |
38 | )
39 | }
40 |
41 | function PopoverAnchor({
42 | ...props
43 | }: React.ComponentProps) {
44 | return
45 | }
46 |
47 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
48 |
--------------------------------------------------------------------------------
/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as ProgressPrimitive from "@radix-ui/react-progress"
5 | import * as React from "react"
6 |
7 | function Progress({
8 | className,
9 | value,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 |
26 |
27 | )
28 | }
29 |
30 | export { Progress }
31 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 | import * as React from "react"
6 |
7 | function ScrollArea({
8 | className,
9 | children,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 |
22 | {children}
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | function ScrollBar({
31 | className,
32 | orientation = "vertical",
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
49 |
53 |
54 | )
55 | }
56 |
57 | export { ScrollArea, ScrollBar }
58 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 | import * as React from "react"
6 |
7 | function Separator({
8 | className,
9 | orientation = "horizontal",
10 | decorative = true,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
24 | )
25 | }
26 |
27 | export { Separator }
28 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner, ToasterProps } from "sonner"
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme()
8 |
9 | return (
10 |
22 | )
23 | }
24 |
25 | export { Toaster }
26 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitive from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Switch({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 |
27 |
28 | )
29 | }
30 |
31 | export { Switch }
32 |
--------------------------------------------------------------------------------
/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 | import * as React from "react"
6 |
7 | const Tabs = TabsPrimitive.Root
8 |
9 | const TabsList = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ))
22 | TabsList.displayName = TabsPrimitive.List.displayName
23 |
24 | const TabsTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, ...props }, ref) => (
28 |
36 | ))
37 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
38 |
39 | const TabsContent = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, ...props }, ref) => (
43 |
51 | ))
52 | TabsContent.displayName = TabsPrimitive.Content.displayName
53 |
54 | export { Tabs, TabsList, TabsTrigger, TabsContent }
55 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import * as React from "react"
3 |
4 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5 | return (
6 |
14 | )
15 | }
16 |
17 | export { Textarea }
18 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { CheckCircle, Info, Warning } from "@phosphor-icons/react/dist/ssr"
4 | import { toast as sonnerToast } from "sonner"
5 | import { Button } from "./button"
6 |
7 | type ToastProps = {
8 | id: string | number
9 | title: string
10 | description?: string
11 | button?: {
12 | label: string
13 | onClick: () => void
14 | }
15 | status?: "error" | "info" | "success" | "warning"
16 | }
17 |
18 | function Toast({ title, description, button, id, status }: ToastProps) {
19 | return (
20 |
21 |
22 | {status === "error" ? (
23 |
24 | ) : null}
25 | {status === "info" ? (
26 |
27 | ) : null}
28 | {status === "success" ? (
29 |
30 | ) : null}
31 |
32 |
{title}
33 | {description && (
34 |
{description}
35 | )}
36 |
37 |
38 | {button ? (
39 |
40 |
51 |
52 | ) : null}
53 |
54 | )
55 | }
56 |
57 | function toast(toast: Omit) {
58 | return sonnerToast.custom(
59 | (id) => (
60 |
67 | ),
68 | {
69 | position: "top-center",
70 | }
71 | )
72 | }
73 |
74 | export { toast }
75 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 | import * as React from "react"
6 |
7 | function TooltipProvider({
8 | delayDuration = 0,
9 | skipDelayDuration = 300,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function TooltipTrigger({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function TooltipContent({
34 | className,
35 | sideOffset = 0,
36 | children,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 | {children}
51 |
52 |
53 |
54 | )
55 | }
56 |
57 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
58 |
--------------------------------------------------------------------------------
/docker-compose.ollama.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | # Ollama service for local LLM hosting
5 | ollama:
6 | image: ollama/ollama:latest
7 | container_name: zola-ollama
8 | ports:
9 | - "11434:11434"
10 | volumes:
11 | - ollama_data:/root/.ollama
12 | environment:
13 | - OLLAMA_HOST=0.0.0.0
14 | restart: unless-stopped
15 | healthcheck:
16 | test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
17 | interval: 30s
18 | timeout: 10s
19 | retries: 3
20 | start_period: 30s
21 |
22 | # Zola application
23 | zola:
24 | build: .
25 | container_name: zola-app
26 | ports:
27 | - "3000:3000"
28 | environment:
29 | - NODE_ENV=production
30 | # Ollama configuration
31 | - OLLAMA_BASE_URL=http://ollama:11434
32 | depends_on:
33 | ollama:
34 | condition: service_healthy
35 | restart: unless-stopped
36 |
37 | volumes:
38 | ollama_data:
39 | driver: local
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | zola:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | ports:
7 | - "3000:3000"
8 | environment:
9 | - NODE_ENV=production
10 | - NEXT_TELEMETRY_DISABLED=1
11 | healthcheck:
12 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
13 | interval: 30s
14 | timeout: 5s
15 | retries: 3
16 | start_period: 10s
17 | restart: unless-stopped
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path"
2 | import { fileURLToPath } from "url"
3 | import { FlatCompat } from "@eslint/eslintrc"
4 |
5 | const __filename = fileURLToPath(import.meta.url)
6 | const __dirname = dirname(__filename)
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | })
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ]
15 |
16 | export default eslintConfig
17 |
--------------------------------------------------------------------------------
/lib/agent-store/api.ts:
--------------------------------------------------------------------------------
1 | import { Agent } from "@/app/types/agent"
2 | import { createClient } from "@/lib/supabase/client"
3 | import { CURATED_AGENTS_SLUGS } from "../config"
4 |
5 | export async function fetchCuratedAgentsFromDb(): Promise {
6 | const supabase = createClient()
7 | if (!supabase) return null
8 |
9 | const { data, error } = await supabase
10 | .from("agents")
11 | .select("*")
12 | .in("slug", CURATED_AGENTS_SLUGS)
13 |
14 | if (error) {
15 | console.error("Error fetching curated agents:", error)
16 | return null
17 | }
18 |
19 | return data
20 | }
21 |
22 | export async function fetchUserAgentsFromDb(
23 | userId: string
24 | ): Promise {
25 | const supabase = createClient()
26 | if (!supabase) return null
27 |
28 | const { data, error } = await supabase
29 | .from("agents")
30 | .select("*")
31 | .eq("creator_id", userId)
32 |
33 | if (error) {
34 | console.error("Error fetching user agents:", error)
35 | return null
36 | }
37 |
38 | return data
39 | }
40 |
41 | export async function fetchAgentBySlugOrId({
42 | slug,
43 | id,
44 | }: {
45 | slug?: string
46 | id?: string | null
47 | }): Promise {
48 | const supabase = createClient()
49 | if (!supabase) return null
50 |
51 | let query = supabase.from("agents").select("*")
52 |
53 | if (slug) {
54 | query = query.eq("slug", slug)
55 | } else if (id) {
56 | query = query.eq("id", id)
57 | } else {
58 | return null
59 | }
60 |
61 | const { data, error } = await query.single()
62 |
63 | if (error || !data) {
64 | console.error("Error fetching agent:", error)
65 | return null
66 | }
67 |
68 | return data
69 | }
70 |
--------------------------------------------------------------------------------
/lib/agent-store/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Agent } from "@/app/types/agent"
2 | import type { LocalAgent } from "@/lib/agents/local-agents"
3 |
4 | export const convertLocalAgentToAgentDb = (localAgent: LocalAgent) => {
5 | const convertedAgent: Agent = {
6 | id: localAgent.id,
7 | name: localAgent.name,
8 | description: "",
9 | system_prompt: localAgent.system_prompt,
10 | tools: [],
11 | slug: localAgent.id,
12 | avatar_url: null,
13 | category: null,
14 | created_at: null,
15 | creator_id: null,
16 | example_inputs: null,
17 | is_public: false,
18 | model_preference: null,
19 | remixable: false,
20 | tags: null,
21 | tools_enabled: true,
22 | updated_at: null,
23 | max_steps: null,
24 | mcp_config: null,
25 | }
26 |
27 | return convertedAgent
28 | }
29 |
--------------------------------------------------------------------------------
/lib/agents/load-agent.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "@/lib/supabase/server"
2 | import { TOOL_REGISTRY, ToolId } from "../tools"
3 | import { localAgents } from "./local-agents"
4 |
5 | export async function loadAgent(agentId: string) {
6 | // Check local agents first
7 | if (localAgents[agentId as keyof typeof localAgents]) {
8 | const localAgent = localAgents[agentId as keyof typeof localAgents]
9 |
10 | return {
11 | systemPrompt: localAgent.system_prompt,
12 | tools: localAgent.tools,
13 | maxSteps: 5,
14 | mcpConfig: null,
15 | }
16 | }
17 |
18 | // Fallback to database agents
19 | const supabase = await createClient()
20 |
21 | if (!supabase) {
22 | throw new Error("Supabase is not configured")
23 | }
24 |
25 | const { data: agent, error } = await supabase
26 | .from("agents")
27 | .select("*")
28 | .eq("id", agentId)
29 | .maybeSingle()
30 |
31 | if (error || !agent) {
32 | throw new Error("Agent not found")
33 | }
34 |
35 | const activeTools = Array.isArray(agent.tools)
36 | ? agent.tools.reduce((acc: Record, toolId: string) => {
37 | const tool = TOOL_REGISTRY[toolId as ToolId]
38 | if (!tool) return acc
39 | if (tool.isAvailable?.() === false) return acc
40 | acc[toolId] = tool
41 | return acc
42 | }, {})
43 | : {}
44 |
45 | return {
46 | systemPrompt: agent.system_prompt,
47 | tools: activeTools,
48 | maxSteps: agent.max_steps ?? 5,
49 | mcpConfig: agent.mcp_config,
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/lib/agents/local-agents.ts:
--------------------------------------------------------------------------------
1 | import { imageSearchTool } from "@/lib/tools/exa/imageSearch/tool"
2 | import { webSearchTool } from "@/lib/tools/exa/webSearch/tool"
3 | import { Tool } from "ai"
4 |
5 | export type LocalAgent = {
6 | id: string
7 | name: string
8 | system_prompt: string
9 | tools: Record
10 | hidden: boolean
11 | }
12 |
13 | export const localAgents: Record = {
14 | search: {
15 | id: "search",
16 | name: "Search",
17 | system_prompt: `You are a smart websearch assistant.
18 |
19 | Always do both of these for every user query — no exception:
20 | - Call imageSearch using the full original user prompt to fetch visual context.
21 | - Call webSearch using the same prompt to find useful links and information.
22 |
23 | Your written response must:
24 | - Be short, clear, and directly useful.
25 | - Include 2–4 relevant links from the webSearch results (with titles if possible).
26 | - Never describe, mention, or refer to images or visuals. The UI will display them automatically.
27 | - Never mention tool names or tool usage.
28 | - Only skip a tool if the user explicitly says “no image” or “no web”.
29 | `,
30 | tools: {
31 | webSearch: webSearchTool,
32 | imageSearch: imageSearchTool,
33 | },
34 | hidden: true,
35 | },
36 | }
37 |
--------------------------------------------------------------------------------
/lib/agents/utils.ts:
--------------------------------------------------------------------------------
1 | import { localAgents } from "./local-agents"
2 |
3 | /**
4 | * Check if an agent ID corresponds to a local agent
5 | */
6 | export function isLocalAgent(agentId: string | null | undefined): boolean {
7 | if (!agentId) return false
8 | return agentId in localAgents
9 | }
10 |
11 | /**
12 | * Filter out local agent IDs, returning null if the agent is local
13 | * This is useful for database operations that should only receive database agent IDs
14 | */
15 | export function filterLocalAgentId(
16 | agentId: string | null | undefined
17 | ): string | null {
18 | if (!agentId) return null
19 | return isLocalAgent(agentId) ? null : agentId
20 | }
21 |
--------------------------------------------------------------------------------
/lib/chat-store/session/provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { usePathname } from "next/navigation"
4 | import { createContext, useContext, useMemo } from "react"
5 |
6 | const ChatSessionContext = createContext<{ chatId: string | null }>({
7 | chatId: null,
8 | })
9 |
10 | export const useChatSession = () => useContext(ChatSessionContext)
11 |
12 | export function ChatSessionProvider({
13 | children,
14 | }: {
15 | children: React.ReactNode
16 | }) {
17 | const pathname = usePathname()
18 | const chatId = useMemo(() => {
19 | if (pathname?.startsWith("/c/")) return pathname.split("/c/")[1]
20 | return null
21 | }, [pathname])
22 |
23 | return (
24 |
25 | {children}
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/lib/chat-store/types.ts:
--------------------------------------------------------------------------------
1 | import type { Tables } from "@/app/types/database.types"
2 |
3 | export type Chat = Tables<"chats">
4 | export type Message = Tables<"messages">
5 | export type Chats = Tables<"chats">
6 |
--------------------------------------------------------------------------------
/lib/csrf.ts:
--------------------------------------------------------------------------------
1 | import { createHash, randomBytes } from "crypto"
2 | import { cookies } from "next/headers"
3 |
4 | const CSRF_SECRET = process.env.CSRF_SECRET!
5 |
6 | export function generateCsrfToken(): string {
7 | const raw = randomBytes(32).toString("hex")
8 | const token = createHash("sha256")
9 | .update(`${raw}${CSRF_SECRET}`)
10 | .digest("hex")
11 | return `${raw}:${token}`
12 | }
13 |
14 | export function validateCsrfToken(fullToken: string): boolean {
15 | const [raw, token] = fullToken.split(":")
16 | if (!raw || !token) return false
17 | const expected = createHash("sha256")
18 | .update(`${raw}${CSRF_SECRET}`)
19 | .digest("hex")
20 | return expected === token
21 | }
22 |
23 | export async function setCsrfCookie() {
24 | const cookieStore = await cookies()
25 | const token = generateCsrfToken()
26 | cookieStore.set("csrf_token", token, {
27 | httpOnly: false,
28 | secure: true,
29 | path: "/",
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/lib/fetch.ts:
--------------------------------------------------------------------------------
1 | export async function fetchClient(input: RequestInfo, init?: RequestInit) {
2 | const csrf = document.cookie
3 | .split("; ")
4 | .find((c) => c.startsWith("csrf_token="))
5 | ?.split("=")[1]
6 |
7 | return fetch(input, {
8 | ...init,
9 | headers: {
10 | ...(init?.headers || {}),
11 | "x-csrf-token": csrf || "",
12 | "Content-Type": "application/json",
13 | },
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/lib/mcp/load-mcp-from-local.ts:
--------------------------------------------------------------------------------
1 | import { experimental_createMCPClient as createMCPClient } from "ai"
2 | import { Experimental_StdioMCPTransport as StdioMCPTransport } from "ai/mcp-stdio"
3 |
4 | export async function loadMCPToolsFromLocal(
5 | command: string,
6 | env: Record = {}
7 | ) {
8 | const mcpClient = await createMCPClient({
9 | transport: new StdioMCPTransport({
10 | command,
11 | args: ["stdio"],
12 | env,
13 | }),
14 | })
15 |
16 | const tools = await mcpClient.tools()
17 | return { tools, close: () => mcpClient.close() }
18 | }
19 |
--------------------------------------------------------------------------------
/lib/mcp/load-mcp-from-url.ts:
--------------------------------------------------------------------------------
1 | import { experimental_createMCPClient as createMCPClient } from "ai"
2 |
3 | export async function loadMCPToolsFromURL(url: string) {
4 | const mcpClient = await createMCPClient({
5 | transport: {
6 | type: "sse",
7 | url,
8 | },
9 | })
10 |
11 | const tools = await mcpClient.tools()
12 | return { tools, close: () => mcpClient.close() }
13 | }
14 |
--------------------------------------------------------------------------------
/lib/models/data/deepseek.ts:
--------------------------------------------------------------------------------
1 | import { createOpenRouter } from "@openrouter/ai-sdk-provider"
2 | import { ModelConfig } from "../types"
3 |
4 | const deepseekModels: ModelConfig[] = [
5 | {
6 | id: "deepseek-r1",
7 | name: "DeepSeek R1",
8 | provider: "DeepSeek",
9 |
10 | providerId: "deepseek",
11 | modelFamily: "DeepSeek",
12 | description:
13 | "Flagship model by DeepSeek, optimized for performance and reliability.",
14 | tags: ["flagship", "reasoning", "performance", "reliability"],
15 | contextWindow: 64000,
16 | inputCost: 0.14,
17 | outputCost: 0.28,
18 | priceUnit: "per 1M tokens",
19 | vision: false,
20 | tools: true,
21 | audio: false,
22 | reasoning: true,
23 | openSource: false,
24 | speed: "Medium",
25 | intelligence: "High",
26 | website: "https://deepseek.com",
27 | apiDocs: "https://platform.deepseek.com/api-docs",
28 | modelPage: "https://deepseek.com",
29 | releasedAt: "2024-04-01",
30 | apiSdk: () =>
31 | createOpenRouter({
32 | apiKey: process.env.OPENROUTER_API_KEY,
33 | }).chat("deepseek/deepseek-r1:free"),
34 | },
35 | {
36 | id: "deepseek-v3",
37 | name: "DeepSeek-V3",
38 | provider: "DeepSeek",
39 | providerId: "deepseek",
40 | modelFamily: "DeepSeek",
41 | description: "Smaller open-weight DeepSeek model for casual or hobby use.",
42 | tags: ["open-source", "smaller", "hobby", "research"],
43 | contextWindow: 32768,
44 | inputCost: 0.0,
45 | outputCost: 0.0,
46 | priceUnit: "per 1M tokens",
47 | vision: false,
48 | tools: true,
49 | audio: false,
50 | reasoning: true,
51 | openSource: true,
52 | speed: "Fast",
53 | intelligence: "Medium",
54 | website: "https://deepseek.com",
55 | apiDocs: "https://github.com/deepseek-ai/deepseek",
56 | modelPage: "https://github.com/deepseek-ai",
57 | releasedAt: "2024-12-26",
58 | apiSdk: () =>
59 | createOpenRouter({
60 | apiKey: process.env.OPENROUTER_API_KEY,
61 | }).chat("deepseek-v3"),
62 | },
63 | ]
64 |
65 | export { deepseekModels }
66 |
--------------------------------------------------------------------------------
/lib/models/index.ts:
--------------------------------------------------------------------------------
1 | import { claudeModels } from "./data/claude"
2 | import { deepseekModels } from "./data/deepseek"
3 | import { grokModels } from "./data/grok"
4 | import { mistralModels } from "./data/mistral"
5 | import { ollamaModels, getOllamaModels } from "./data/ollama"
6 | import { openaiModels } from "./data/openai"
7 | import { ModelConfig } from "./types"
8 |
9 | // Static models (always available)
10 | export const STATIC_MODELS: ModelConfig[] = [
11 | ...openaiModels,
12 | ...mistralModels,
13 | ...deepseekModels,
14 | ...claudeModels,
15 | ...grokModels,
16 | ...ollamaModels, // Static fallback Ollama models
17 |
18 | // not ready
19 | // ...llamaModels,
20 | ]
21 |
22 | // Dynamic models cache
23 | let dynamicModelsCache: ModelConfig[] | null = null
24 | let lastFetchTime = 0
25 | const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
26 |
27 | // Function to get all models including dynamically detected ones
28 | export async function getAllModels(): Promise {
29 | const now = Date.now()
30 |
31 | // Use cache if it's still valid
32 | if (dynamicModelsCache && (now - lastFetchTime) < CACHE_DURATION) {
33 | return dynamicModelsCache
34 | }
35 |
36 | try {
37 | // Get dynamically detected Ollama models
38 | const detectedOllamaModels = await getOllamaModels()
39 |
40 | // Combine static models (excluding static Ollama models) with detected ones
41 | const staticModelsWithoutOllama = STATIC_MODELS.filter(
42 | model => model.providerId !== "ollama"
43 | )
44 |
45 | dynamicModelsCache = [
46 | ...staticModelsWithoutOllama,
47 | ...detectedOllamaModels,
48 | ]
49 |
50 | lastFetchTime = now
51 | return dynamicModelsCache
52 | } catch (error) {
53 | console.warn("Failed to load dynamic models, using static models:", error)
54 | return STATIC_MODELS
55 | }
56 | }
57 |
58 | // Synchronous function to get model info for simple lookups
59 | // This uses cached data if available, otherwise falls back to static models
60 | export function getModelInfo(modelId: string): ModelConfig | undefined {
61 | // First check the cache if it exists
62 | if (dynamicModelsCache) {
63 | return dynamicModelsCache.find(model => model.id === modelId)
64 | }
65 |
66 | // Fall back to static models for immediate lookup
67 | return STATIC_MODELS.find(model => model.id === modelId)
68 | }
69 |
70 | // For backward compatibility - static models only
71 | export const MODELS: ModelConfig[] = STATIC_MODELS
72 |
73 | // Function to refresh the models cache
74 | export function refreshModelsCache(): void {
75 | dynamicModelsCache = null
76 | lastFetchTime = 0
77 | }
78 |
--------------------------------------------------------------------------------
/lib/models/types.ts:
--------------------------------------------------------------------------------
1 | import { LanguageModelV1 } from "ai"
2 |
3 | type ModelConfig = {
4 | id: string // "gpt-4.1-nano" // same from AI SDKs
5 | name: string // "GPT-4.1 Nano"
6 | provider: string // "OpenAI", "Mistral", etc.
7 | providerId: string // "openai", "mistral", etc.
8 | modelFamily?: string // "GPT-4", "Claude 3", etc.
9 |
10 | description?: string // Short 1–2 line summary
11 | tags?: string[] // ["fast", "cheap", "vision", "OSS"]
12 |
13 | contextWindow?: number // in tokens
14 | inputCost?: number // USD per 1M input tokens
15 | outputCost?: number // USD per 1M output tokens
16 | priceUnit?: string // "per 1M tokens", "per image", etc.
17 |
18 | vision?: boolean
19 | tools?: boolean
20 | audio?: boolean
21 | reasoning?: boolean
22 | openSource?: boolean
23 |
24 | speed?: "Fast" | "Medium" | "Slow"
25 | intelligence?: "Low" | "Medium" | "High"
26 |
27 | website?: string // official website (e.g. https://openai.com)
28 | apiDocs?: string // official API docs (e.g. https://platform.openai.com/docs/api-reference)
29 | modelPage?: string // official product page (e.g. https://x.ai/news/grok-2)
30 | releasedAt?: string // "2024-12-01" (optional, for tracking changes)
31 |
32 | // apiSdk?: () => LanguageModelV1 // "openai("gpt-4.1-nano")"
33 | apiSdk?: () => LanguageModelV1
34 | }
35 |
36 | export type { ModelConfig }
37 |
--------------------------------------------------------------------------------
/lib/motion.ts:
--------------------------------------------------------------------------------
1 | export const TRANSITION_SUGGESTIONS = {
2 | duration: 0.25,
3 | type: "spring",
4 | bounce: 0,
5 | }
6 |
--------------------------------------------------------------------------------
/lib/openproviders/env.ts:
--------------------------------------------------------------------------------
1 | export const env = {
2 | OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
3 | MISTRAL_API_KEY: process.env.MISTRAL_API_KEY!,
4 | GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY!,
5 | ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
6 | XAI_API_KEY: process.env.XAI_API_KEY!,
7 | }
8 |
--------------------------------------------------------------------------------
/lib/providers/index.ts:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Anthropic from "@/components/icons/anthropic"
3 | import Claude from "@/components/icons/claude"
4 | import DeepSeek from "@/components/icons/deepseek"
5 | import Gemini from "@/components/icons/gemini"
6 | import Google from "@/components/icons/google"
7 | import Grok from "@/components/icons/grok"
8 | import Mistral from "@/components/icons/mistral"
9 | import Ollama from "@/components/icons/ollama"
10 | import OpenAI from "@/components/icons/openai"
11 | import OpenRouter from "@/components/icons/openrouter"
12 | import Xai from "@/components/icons/xai"
13 |
14 | export type Provider = {
15 | id: string
16 | name: string
17 | available: boolean
18 | icon: React.ComponentType>
19 | }
20 |
21 | export const PROVIDERS: Provider[] = [
22 | {
23 | id: "openrouter",
24 | name: "OpenRouter",
25 | icon: OpenRouter,
26 | },
27 | {
28 | id: "openai",
29 | name: "OpenAI",
30 | icon: OpenAI,
31 | },
32 | {
33 | id: "mistral",
34 | name: "Mistral",
35 | icon: Mistral,
36 | },
37 | {
38 | id: "deepseek",
39 | name: "DeepSeek",
40 | icon: DeepSeek,
41 | },
42 | {
43 | id: "gemini",
44 | name: "Gemini",
45 | icon: Gemini,
46 | },
47 | {
48 | id: "claude",
49 | name: "Claude",
50 | icon: Claude,
51 | },
52 | {
53 | id: "grok",
54 | name: "Grok",
55 | icon: Grok,
56 | },
57 | {
58 | id: "xai",
59 | name: "XAI",
60 | icon: Xai,
61 | },
62 | {
63 | id: "google",
64 | name: "Google",
65 | icon: Google,
66 | },
67 | {
68 | id: "anthropic",
69 | name: "Anthropic",
70 | icon: Anthropic,
71 | },
72 | {
73 | id: "ollama",
74 | name: "Ollama",
75 | icon: Ollama,
76 | },
77 | ] as Provider[]
78 |
--------------------------------------------------------------------------------
/lib/routes.ts:
--------------------------------------------------------------------------------
1 | export const API_ROUTE_CHAT = "/api/chat"
2 | export const API_ROUTE_CREATE_CHAT = "/api/create-chat"
3 | export const API_ROUTE_CREATE_GUEST = "/api/create-guest"
4 | export const API_ROUTE_UPDATE_CHAT_MODEL = "/api/update-chat-model"
5 | export const API_ROUTE_CSRF = "/api/csrf"
6 | export const API_ROUTE_CREATE_AGENT = "/api/create-agent"
7 | export const API_ROUTE_DELETE_AGENT = "/api/delete-agent"
8 | export const API_ROUTE_UPDATE_CHAT_AGENT = "/api/update-chat-agent"
9 |
--------------------------------------------------------------------------------
/lib/sanitize.ts:
--------------------------------------------------------------------------------
1 | import createDOMPurify from "dompurify"
2 | import { JSDOM } from "jsdom"
3 |
4 | const window = new JSDOM("").window
5 | const DOMPurify = createDOMPurify(window)
6 |
7 | export function sanitizeUserInput(input: string): string {
8 | return DOMPurify.sanitize(input)
9 | }
10 |
--------------------------------------------------------------------------------
/lib/server/api.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "@/lib/supabase/server"
2 | import { createGuestServerClient } from "@/lib/supabase/server-guest"
3 | import { isSupabaseEnabled } from "../supabase/config"
4 |
5 | /**
6 | * Validates the user's identity
7 | * @param userId - The ID of the user.
8 | * @param isAuthenticated - Whether the user is authenticated.
9 | * @returns The Supabase client.
10 | */
11 | export async function validateUserIdentity(
12 | userId: string,
13 | isAuthenticated: boolean
14 | ) {
15 | if (!isSupabaseEnabled) {
16 | return null
17 | }
18 |
19 | const supabase = isAuthenticated
20 | ? await createClient()
21 | : await createGuestServerClient()
22 |
23 | if (!supabase) {
24 | throw new Error("Failed to initialize Supabase client")
25 | }
26 |
27 | if (isAuthenticated) {
28 | const { data: authData, error: authError } = await supabase.auth.getUser()
29 |
30 | if (authError || !authData?.user?.id) {
31 | throw new Error("Unable to get authenticated user")
32 | }
33 |
34 | if (authData.user.id !== userId) {
35 | throw new Error("User ID does not match authenticated user")
36 | }
37 | } else {
38 | const { data: userRecord, error: userError } = await supabase
39 | .from("users")
40 | .select("id")
41 | .eq("id", userId)
42 | .eq("anonymous", true)
43 | .maybeSingle()
44 |
45 | if (userError || !userRecord) {
46 | throw new Error("Invalid or missing guest user")
47 | }
48 | }
49 |
50 | return supabase
51 | }
52 |
--------------------------------------------------------------------------------
/lib/settings-store/store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 | import { persist } from 'zustand/middleware'
3 | import { SettingsState, SettingsActions, ProviderSettings, OpenAICompatibleProvider } from './types'
4 |
5 | const defaultProviders: ProviderSettings[] = [
6 | {
7 | id: 'openai',
8 | name: 'OpenAI',
9 | enabled: true,
10 | type: 'openai',
11 | },
12 | {
13 | id: 'anthropic',
14 | name: 'Anthropic',
15 | enabled: true,
16 | type: 'anthropic',
17 | },
18 | {
19 | id: 'google',
20 | name: 'Google',
21 | enabled: true,
22 | type: 'google',
23 | },
24 | {
25 | id: 'mistral',
26 | name: 'Mistral AI',
27 | enabled: true,
28 | type: 'mistral',
29 | },
30 | {
31 | id: 'xai',
32 | name: 'xAI',
33 | enabled: true,
34 | type: 'xai',
35 | },
36 | {
37 | id: 'deepseek',
38 | name: 'DeepSeek',
39 | enabled: true,
40 | type: 'deepseek',
41 | },
42 | {
43 | id: 'ollama',
44 | name: 'Ollama',
45 | enabled: true,
46 | type: 'ollama',
47 | baseUrl: typeof window !== 'undefined' ? 'http://localhost:11434' : process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
48 | },
49 | ]
50 |
51 | type SettingsStore = SettingsState & SettingsActions
52 |
53 | export const useSettingsStore = create()(
54 | persist(
55 | (set) => ({
56 | providers: defaultProviders,
57 | customProviders: [],
58 |
59 | updateProvider: (id: string, settings: Partial) => {
60 | set((state: SettingsStore) => ({
61 | providers: state.providers.map((provider: ProviderSettings) =>
62 | provider.id === id ? { ...provider, ...settings } : provider
63 | ),
64 | }))
65 | },
66 |
67 | addCustomProvider: (provider: OpenAICompatibleProvider) => {
68 | set((state: SettingsStore) => ({
69 | customProviders: [...state.customProviders, provider],
70 | }))
71 | },
72 |
73 | removeCustomProvider: (id: string) => {
74 | set((state: SettingsStore) => ({
75 | customProviders: state.customProviders.filter((provider: OpenAICompatibleProvider) => provider.id !== id),
76 | }))
77 | },
78 |
79 | resetSettings: () => {
80 | set({
81 | providers: defaultProviders,
82 | customProviders: [],
83 | })
84 | },
85 | }),
86 | {
87 | name: 'zola-settings',
88 | version: 1,
89 | }
90 | )
91 | )
--------------------------------------------------------------------------------
/lib/settings-store/types.ts:
--------------------------------------------------------------------------------
1 | export interface ProviderSettings {
2 | id: string
3 | name: string
4 | enabled: boolean
5 | apiKey?: string
6 | baseUrl?: string
7 | type: 'openai' | 'anthropic' | 'google' | 'mistral' | 'xai' | 'deepseek' | 'ollama' | 'openai-compatible'
8 | }
9 |
10 | export interface OpenAICompatibleProvider extends ProviderSettings {
11 | type: 'openai-compatible'
12 | baseUrl: string
13 | name: string
14 | models?: string[]
15 | }
16 |
17 | export interface SettingsState {
18 | providers: ProviderSettings[]
19 | customProviders: OpenAICompatibleProvider[]
20 | }
21 |
22 | export interface SettingsActions {
23 | updateProvider: (id: string, settings: Partial) => void
24 | addCustomProvider: (provider: OpenAICompatibleProvider) => void
25 | removeCustomProvider: (id: string) => void
26 | resetSettings: () => void
27 | }
--------------------------------------------------------------------------------
/lib/supabase/client.ts:
--------------------------------------------------------------------------------
1 | import { Database } from "@/app/types/database.types"
2 | import { createBrowserClient } from "@supabase/ssr"
3 | import { isSupabaseEnabled } from "./config"
4 |
5 | export function createClient() {
6 | if (!isSupabaseEnabled) {
7 | return null
8 | }
9 |
10 | return createBrowserClient(
11 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
12 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/lib/supabase/config.ts:
--------------------------------------------------------------------------------
1 | export const isSupabaseEnabled = Boolean(
2 | process.env.NEXT_PUBLIC_SUPABASE_URL &&
3 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
4 | )
5 |
--------------------------------------------------------------------------------
/lib/supabase/server-guest.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from "@/app/types/database.types"
2 | import { createServerClient } from "@supabase/ssr"
3 | import { isSupabaseEnabled } from "./config"
4 |
5 | export async function createGuestServerClient() {
6 | if (!isSupabaseEnabled) {
7 | return null
8 | }
9 |
10 | return createServerClient(
11 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
12 | process.env.SUPABASE_SERVICE_ROLE!,
13 | {
14 | cookies: {
15 | getAll: () => [],
16 | setAll: () => {},
17 | },
18 | }
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/lib/supabase/server.ts:
--------------------------------------------------------------------------------
1 | import { Database } from "@/app/types/database.types"
2 | import { createServerClient } from "@supabase/ssr"
3 | import { cookies } from "next/headers"
4 | import { isSupabaseEnabled } from "./config"
5 |
6 | export const createClient = async () => {
7 | if (!isSupabaseEnabled) {
8 | return null
9 | }
10 |
11 | const cookieStore = await cookies()
12 |
13 | return createServerClient(
14 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
16 | {
17 | cookies: {
18 | getAll: () => cookieStore.getAll(),
19 | setAll: (cookiesToSet) => {
20 | try {
21 | cookiesToSet.forEach(({ name, value, options }) => {
22 | cookieStore.set(name, value, options)
23 | })
24 | } catch {
25 | // ignore for middleware
26 | }
27 | },
28 | },
29 | }
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/lib/tools/exa/crawl/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | label: "Crawl URL",
3 | description:
4 | "Extract content from a specific URL using Exa. Useful for articles, PDFs, or webpages when you know the exact URL.",
5 | envVars: ["EXA_API_KEY"],
6 | optionalVars: [],
7 | docsUrl: "https://docs.exa.ai",
8 | }
9 |
--------------------------------------------------------------------------------
/lib/tools/exa/crawl/run.ts:
--------------------------------------------------------------------------------
1 | type Input = {
2 | url: string
3 | }
4 |
5 | export async function runCrawl(input: Input) {
6 | const { url } = input
7 |
8 | if (!process.env.EXA_API_KEY) {
9 | return {
10 | content: [
11 | {
12 | type: "text" as const,
13 | text: "Missing EXA_API_KEY in .env.local.",
14 | },
15 | ],
16 | isError: true,
17 | }
18 | }
19 |
20 | try {
21 | const res = await fetch("https://api.exa.ai/contents", {
22 | method: "POST",
23 | headers: {
24 | "Content-Type": "application/json",
25 | Accept: "application/json",
26 | "x-api-key": process.env.EXA_API_KEY,
27 | },
28 | body: JSON.stringify({
29 | ids: [url],
30 | text: true,
31 | livecrawl: "always",
32 | }),
33 | })
34 |
35 | if (!res.ok) {
36 | const errData = await res.json()
37 | throw new Error(errData?.message || "Unknown Exa error")
38 | }
39 |
40 | const data = await res.json()
41 |
42 | if (!data?.results?.length) {
43 | return {
44 | content: [
45 | {
46 | type: "text" as const,
47 | text: "No content found at the specified URL. Please check the URL and try again.",
48 | },
49 | ],
50 | }
51 | }
52 |
53 | return {
54 | content: [
55 | {
56 | type: "text" as const,
57 | text: JSON.stringify(data, null, 2),
58 | },
59 | ],
60 | }
61 | } catch (err: unknown) {
62 | const errorMessage = err instanceof Error ? err.message : String(err)
63 | return {
64 | content: [
65 | {
66 | type: "text" as const,
67 | text: `Crawling error: ${errorMessage}`,
68 | },
69 | ],
70 | isError: true,
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/lib/tools/exa/crawl/tool.ts:
--------------------------------------------------------------------------------
1 | import { tool } from "ai"
2 | import { z } from "zod"
3 | import { runCrawl } from "./run"
4 |
5 | export const crawlTool = tool({
6 | description:
7 | "Extract content from a specific URL using Exa. Useful for articles, PDFs, or any webpage when you know the exact URL.",
8 | parameters: z.object({
9 | url: z.string().url().describe("The URL to crawl"),
10 | }),
11 | async execute({ url }) {
12 | return await runCrawl({ url })
13 | },
14 | })
15 |
--------------------------------------------------------------------------------
/lib/tools/exa/imageSearch/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | label: "Image Search",
3 | description: "Search for 2–3 relevant images using Exa for visual display.",
4 | envVars: ["EXA_API_KEY"],
5 | docsUrl: "https://docs.exa.ai",
6 | }
7 |
--------------------------------------------------------------------------------
/lib/tools/exa/imageSearch/run.ts:
--------------------------------------------------------------------------------
1 | type Input = {
2 | query: string
3 | numResults?: number
4 | }
5 |
6 | export async function runImageSearch(input: Input) {
7 | const { query, numResults = 3 } = input
8 |
9 | if (!process.env.EXA_API_KEY) {
10 | return {
11 | content: [
12 | {
13 | type: "text" as const,
14 | text: "Missing EXA_API_KEY in .env.local.",
15 | },
16 | ],
17 | isError: true,
18 | }
19 | }
20 |
21 | try {
22 | const res = await fetch("https://api.exa.ai/search", {
23 | method: "POST",
24 | headers: {
25 | "Content-Type": "application/json",
26 | Accept: "application/json",
27 | "x-api-key": process.env.EXA_API_KEY,
28 | },
29 | body: JSON.stringify({
30 | query,
31 | type: "auto",
32 | numResults,
33 | contents: {
34 | text: { maxCharacters: 200 },
35 | livecrawl: "always",
36 | },
37 | }),
38 | })
39 |
40 | if (!res.ok) {
41 | const error = await res.json()
42 | throw new Error(error?.message || "Unknown error from Exa")
43 | }
44 |
45 | const data = await res.json()
46 |
47 | const imageResults = data.results
48 | .map((r: any) => ({
49 | title: r.title,
50 | imageUrl: r.image || r.imageUrl || null,
51 | sourceUrl: r.url,
52 | }))
53 | .filter((r: any) => r.imageUrl)
54 | .slice(0, numResults)
55 |
56 | return {
57 | content: [
58 | {
59 | type: "images" as const,
60 | results: imageResults,
61 | },
62 | ],
63 | }
64 | } catch (err: unknown) {
65 | const errorMessage = err instanceof Error ? err.message : String(err)
66 | return {
67 | content: [
68 | {
69 | type: "text" as const,
70 | text: `Image search error: ${errorMessage}`,
71 | },
72 | ],
73 | isError: true,
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/lib/tools/exa/imageSearch/tool.ts:
--------------------------------------------------------------------------------
1 | import { tool } from "ai"
2 | import { z } from "zod"
3 | import { runImageSearch } from "./run"
4 |
5 | export const imageSearchTool = tool({
6 | description: "Search for images using Exa.",
7 | parameters: z.object({
8 | query: z.string().describe("The topic to search for images"),
9 | numResults: z
10 | .number()
11 | .optional()
12 | .describe("Max number of images (default 3)"),
13 | }),
14 | async execute({ query, numResults }) {
15 | return await runImageSearch({ query, numResults })
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/lib/tools/exa/index.ts:
--------------------------------------------------------------------------------
1 | import { config as crawlConfig } from "./crawl/config"
2 | import { crawlTool } from "./crawl/tool"
3 | import { config as imageSearchConfig } from "./imageSearch/config"
4 | import { imageSearchTool } from "./imageSearch/tool"
5 | import { config as webSearchConfig } from "./webSearch/config"
6 | import { webSearchTool } from "./webSearch/tool"
7 |
8 | const isAvailable = (envVars: string[]) => {
9 | return envVars.every((v) => !!process.env[v])
10 | }
11 |
12 | export const exaTools = {
13 | exaWebSearch: {
14 | ...webSearchTool,
15 | isAvailable: () => isAvailable(webSearchConfig.envVars),
16 | },
17 | exaCrawl: {
18 | ...crawlTool,
19 | isAvailable: () => isAvailable(crawlConfig.envVars),
20 | },
21 | exaImageSearch: {
22 | ...imageSearchTool,
23 | isAvailable: () => isAvailable(imageSearchConfig.envVars),
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/lib/tools/exa/webSearch/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | label: "Web Search",
3 | description:
4 | "Search the web using Exa. Returns relevant results with live crawling.",
5 | envVars: ["EXA_API_KEY"],
6 | optionalVars: ["EXA_INDEX_ID"],
7 | docsUrl: "https://docs.exa.ai",
8 | }
9 |
--------------------------------------------------------------------------------
/lib/tools/exa/webSearch/run.ts:
--------------------------------------------------------------------------------
1 | type Input = {
2 | query: string
3 | numResults?: number
4 | }
5 |
6 | export async function runWebSearch(input: Input) {
7 | const { query, numResults = 5 } = input
8 |
9 | if (!process.env.EXA_API_KEY) {
10 | return {
11 | content: [
12 | {
13 | type: "text" as const,
14 | text: "Missing EXA_API_KEY in .env.local.",
15 | },
16 | ],
17 | isError: true,
18 | }
19 | }
20 |
21 | try {
22 | const res = await fetch("https://api.exa.ai/search", {
23 | method: "POST",
24 | headers: {
25 | "Content-Type": "application/json",
26 | Accept: "application/json",
27 | "x-api-key": process.env.EXA_API_KEY,
28 | },
29 | body: JSON.stringify({
30 | query,
31 | type: "auto",
32 | numResults,
33 | contents: {
34 | text: {
35 | maxCharacters: 12000,
36 | },
37 | livecrawl: "always",
38 | },
39 | }),
40 | })
41 |
42 | if (!res.ok) {
43 | const error = await res.json()
44 | throw new Error(error?.message || "Unknown error from Exa")
45 | }
46 |
47 | const data = await res.json()
48 |
49 | if (!data?.results?.length) {
50 | return {
51 | content: [
52 | {
53 | type: "text" as const,
54 | text: "No search results found.",
55 | },
56 | ],
57 | }
58 | }
59 |
60 | return {
61 | content: [
62 | {
63 | type: "text" as const,
64 | text: JSON.stringify(data.results, null, 2),
65 | },
66 | ],
67 | }
68 | } catch (err: unknown) {
69 | const errorMessage = err instanceof Error ? err.message : String(err)
70 | return {
71 | content: [
72 | {
73 | type: "text" as const,
74 | text: `Search error: ${errorMessage}`,
75 | },
76 | ],
77 | isError: true,
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/lib/tools/exa/webSearch/tool.ts:
--------------------------------------------------------------------------------
1 | import { tool } from "ai"
2 | import { z } from "zod"
3 | import { runWebSearch } from "./run"
4 |
5 | export const webSearchTool = tool({
6 | description:
7 | "Search the web using Exa. Returns relevant results with live crawling.",
8 | parameters: z.object({
9 | query: z.string().describe("Search query"),
10 | numResults: z
11 | .number()
12 | .optional()
13 | .describe("Number of results (default: 5)"),
14 | }),
15 | async execute({ query, numResults }) {
16 | return await runWebSearch({ query, numResults })
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/lib/tools/index.ts:
--------------------------------------------------------------------------------
1 | import { exaTools } from "./exa/index"
2 |
3 | export const TOOL_REGISTRY = {
4 | ...exaTools,
5 | // future: ...githubTools, ...huggingfaceTools, etc.
6 | }
7 |
8 | export type ToolId = keyof typeof TOOL_REGISTRY
9 |
10 | export const getAvailableTools = () =>
11 | Object.entries(TOOL_REGISTRY)
12 | .filter(([, tool]) => tool.isAvailable)
13 | .map(([id, tool]) => ({ ...tool, id }))
14 |
15 | export const getAllTools = () =>
16 | Object.entries(TOOL_REGISTRY).map(([id, tool]) => ({
17 | ...tool,
18 | id,
19 | }))
20 |
--------------------------------------------------------------------------------
/lib/user-store/api.ts:
--------------------------------------------------------------------------------
1 | // @todo: move in /lib/user/api.ts
2 | import { UserProfile } from "@/app/types/user"
3 | import { toast } from "@/components/ui/toast"
4 | import { createClient } from "@/lib/supabase/client"
5 |
6 | export async function fetchUserProfile(
7 | id: string
8 | ): Promise {
9 | const supabase = createClient()
10 | if (!supabase) return null
11 |
12 | const { data, error } = await supabase
13 | .from("users")
14 | .select("*")
15 | .eq("id", id)
16 | .single()
17 |
18 | if (error || !data) {
19 | console.error("Failed to fetch user:", error)
20 | return null
21 | }
22 |
23 | return {
24 | ...data,
25 | profile_image: data.profile_image || "",
26 | display_name: data.display_name || "",
27 | }
28 | }
29 |
30 | export async function updateUserProfile(
31 | id: string,
32 | updates: Partial
33 | ): Promise {
34 | const supabase = createClient()
35 | if (!supabase) return false
36 |
37 | const { error } = await supabase.from("users").update(updates).eq("id", id)
38 |
39 | if (error) {
40 | console.error("Failed to update user:", error)
41 | return false
42 | }
43 |
44 | return true
45 | }
46 |
47 | export async function signOutUser(): Promise {
48 | const supabase = createClient()
49 | if (!supabase) {
50 | toast({
51 | title: "Sign out is not supported in this deployment",
52 | status: "info",
53 | })
54 | return false
55 | }
56 |
57 | const { error } = await supabase.auth.signOut()
58 | if (error) {
59 | console.error("Failed to sign out:", error)
60 | return false
61 | }
62 |
63 | return true
64 | }
65 |
66 | export function subscribeToUserUpdates(
67 | userId: string,
68 | onUpdate: (newData: Partial) => void
69 | ) {
70 | const supabase = createClient()
71 | if (!supabase) return () => {}
72 |
73 | const channel = supabase
74 | .channel(`public:users:id=eq.${userId}`)
75 | .on(
76 | "postgres_changes",
77 | {
78 | event: "UPDATE",
79 | schema: "public",
80 | table: "users",
81 | filter: `id=eq.${userId}`,
82 | },
83 | (payload) => {
84 | onUpdate(payload.new as Partial)
85 | }
86 | )
87 | .subscribe()
88 |
89 | return () => {
90 | supabase.removeChannel(channel)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/lib/user/api.ts:
--------------------------------------------------------------------------------
1 | import type { UserProfile } from "@/app/types/user"
2 | import { isSupabaseEnabled } from "@/lib/supabase/config"
3 | import { createClient } from "@/lib/supabase/server"
4 | import { MODEL_DEFAULT } from "../config"
5 |
6 | export async function getSupabaseUser() {
7 | const supabase = await createClient()
8 | if (!supabase) return { supabase: null, user: null }
9 |
10 | const { data } = await supabase.auth.getUser()
11 | return {
12 | supabase,
13 | user: data.user ?? null,
14 | }
15 | }
16 |
17 | export async function getUserProfile(): Promise {
18 | if (!isSupabaseEnabled) {
19 | return {
20 | id: "guest",
21 | email: "guest@zola.chat",
22 | display_name: "Guest",
23 | profile_image: "",
24 | anonymous: true,
25 | created_at: "",
26 | daily_message_count: 0,
27 | daily_reset: "",
28 | message_count: 0,
29 | preferred_model: MODEL_DEFAULT,
30 | premium: false,
31 | last_active_at: "",
32 | daily_pro_message_count: 0,
33 | daily_pro_reset: "",
34 | system_prompt: "",
35 | }
36 | }
37 |
38 | const { supabase, user } = await getSupabaseUser()
39 | if (!supabase || !user) return null
40 |
41 | const { data: userProfileData } = await supabase
42 | .from("users")
43 | .select("*")
44 | .eq("id", user.id)
45 | .single()
46 |
47 | return {
48 | ...userProfileData,
49 | profile_image: user.user_metadata?.avatar_url ?? "",
50 | display_name: user.user_metadata?.name ?? "",
51 | } as UserProfile
52 | }
53 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | /**
9 | * Format a number with commas for thousands, etc
10 | */
11 | export function formatNumber(n: number) {
12 | return new Intl.NumberFormat("en-US").format(n)
13 | }
14 |
15 | /**
16 | * Creates a debounced function that delays invoking the provided function until after
17 | * the specified wait time has elapsed since the last time it was invoked.
18 | */
19 | export function debounce unknown>(
20 | func: T,
21 | wait: number
22 | ): (...args: Parameters) => void {
23 | let timeout: NodeJS.Timeout | null = null
24 |
25 | return function (...args: Parameters): void {
26 | if (timeout) {
27 | clearTimeout(timeout)
28 | }
29 |
30 | timeout = setTimeout(() => {
31 | func(...args)
32 | }, wait)
33 | }
34 | }
35 |
36 | export const isDev = process.env.NODE_ENV === "development"
37 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { updateSession } from "@/utils/supabase/middleware"
2 | import { NextResponse, type NextRequest } from "next/server"
3 | import { validateCsrfToken } from "./lib/csrf"
4 |
5 | export async function middleware(request: NextRequest) {
6 | const response = await updateSession(request)
7 |
8 | // CSRF protection for state-changing requests
9 | if (["POST", "PUT", "DELETE"].includes(request.method)) {
10 | const csrfCookie = request.cookies.get("csrf_token")?.value
11 | const headerToken = request.headers.get("x-csrf-token")
12 |
13 | if (!csrfCookie || !headerToken || !validateCsrfToken(headerToken)) {
14 | return new NextResponse("Invalid CSRF token", { status: 403 })
15 | }
16 | }
17 |
18 | // CSP for development and production
19 | const isDev = process.env.NODE_ENV === "development"
20 |
21 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
22 | const supabaseDomain = supabaseUrl ? new URL(supabaseUrl).origin : ""
23 |
24 | response.headers.set(
25 | "Content-Security-Policy",
26 | isDev
27 | ? `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; connect-src 'self' wss: https://api.openai.com https://api.mistral.ai https://api.supabase.com ${supabaseDomain} https://api.github.com;`
28 | : `default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://analytics.umami.is https://vercel.live; frame-src 'self' https://vercel.live; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; connect-src 'self' wss: https://api.openai.com https://api.mistral.ai https://api.supabase.com ${supabaseDomain} https://api-gateway.umami.dev https://api.github.com;`
29 | )
30 |
31 | return response
32 | }
33 |
34 | export const config = {
35 | matcher: [
36 | "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
37 | ],
38 | runtime: "nodejs",
39 | }
40 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next"
2 |
3 | const withBundleAnalyzer = require("@next/bundle-analyzer")({
4 | enabled: process.env.ANALYZE === "true",
5 | })
6 |
7 | const nextConfig: NextConfig = withBundleAnalyzer({
8 | output: 'standalone',
9 | experimental: {
10 | optimizePackageImports: ["@phosphor-icons/react"],
11 | nodeMiddleware: true,
12 | },
13 | eslint: {
14 | // @todo: remove before going live
15 | ignoreDuringBuilds: true,
16 | },
17 | })
18 |
19 | export default nextConfig
20 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | }
4 |
5 | export default config
6 |
--------------------------------------------------------------------------------
/public/banner_cloud.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/public/banner_cloud.jpg
--------------------------------------------------------------------------------
/public/banner_forest.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/public/banner_forest.jpg
--------------------------------------------------------------------------------
/public/banner_ocean.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/public/banner_ocean.jpg
--------------------------------------------------------------------------------
/public/cover_zola.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibelick/zola/d9251c1e768453882e583f526e6d71a3a880ca10/public/cover_zola.webp
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/utils/supabase/middleware.ts:
--------------------------------------------------------------------------------
1 | import { isSupabaseEnabled } from "@/lib/supabase/config"
2 | import { createServerClient } from "@supabase/ssr"
3 | import { NextResponse, type NextRequest } from "next/server"
4 |
5 | export async function updateSession(request: NextRequest) {
6 | if (!isSupabaseEnabled) {
7 | return NextResponse.next({
8 | request,
9 | })
10 | }
11 |
12 | let supabaseResponse = NextResponse.next({
13 | request,
14 | })
15 |
16 | const supabase = createServerClient(
17 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
19 | {
20 | cookies: {
21 | getAll() {
22 | return request.cookies.getAll()
23 | },
24 | setAll(cookiesToSet) {
25 | cookiesToSet.forEach(({ name, value, options }) =>
26 | request.cookies.set(name, value)
27 | )
28 | supabaseResponse = NextResponse.next({
29 | request,
30 | })
31 | cookiesToSet.forEach(({ name, value, options }) =>
32 | supabaseResponse.cookies.set(name, value, options)
33 | )
34 | },
35 | },
36 | }
37 | )
38 |
39 | // Do not run code between createServerClient and
40 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug
41 | // issues with users being randomly logged out.
42 |
43 | // IMPORTANT: DO NOT REMOVE auth.getUser()
44 |
45 | const {
46 | data: { user },
47 | } = await supabase.auth.getUser()
48 |
49 | // if (
50 | // !user &&
51 | // !request.nextUrl.pathname.startsWith('/login') &&
52 | // !request.nextUrl.pathname.startsWith('/auth')
53 | // ) {
54 | // // no user, potentially respond by redirecting the user to the login page
55 | // const url = request.nextUrl.clone();
56 | // url.pathname = '/login';
57 | // return NextResponse.redirect(url);
58 | // }
59 |
60 | // IMPORTANT: You *must* return the supabaseResponse object as it is.
61 | // If you're creating a new response object with NextResponse.next() make sure to:
62 | // 1. Pass the request in it, like so:
63 | // const myNewResponse = NextResponse.next({ request })
64 | // 2. Copy over the cookies, like so:
65 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
66 | // 3. Change the myNewResponse object to fit your needs, but avoid changing
67 | // the cookies!
68 | // 4. Finally:
69 | // return myNewResponse
70 | // If this is not done, you may be causing the browser and server to go out
71 | // of sync and terminate the user's session prematurely!
72 |
73 | return supabaseResponse
74 | }
75 |
--------------------------------------------------------------------------------