├── .cursor └── rules │ ├── create-db-functions.mdc │ ├── create-migration.mdc │ ├── create-rls-policies.mdc │ ├── postgres-sql-style-guide.mdc │ └── writing-supabase-edge-functions.mdc ├── .env.local.example ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── docker-build.yml ├── .gitignore ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── api │ ├── advanced-search │ │ └── route.ts │ ├── chat │ │ ├── [id] │ │ │ └── route.ts │ │ └── route.ts │ └── chats │ │ └── route.ts ├── auth │ ├── confirm │ │ └── route.ts │ ├── error │ │ └── page.tsx │ ├── forgot-password │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── oauth │ │ └── route.ts │ ├── sign-up-success │ │ └── page.tsx │ ├── sign-up │ │ └── page.tsx │ └── update-password │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.png ├── page.tsx ├── search │ ├── [id] │ │ └── page.tsx │ ├── loading.tsx │ └── page.tsx └── share │ ├── [id] │ └── page.tsx │ └── loading.tsx ├── bun.lock ├── components.json ├── components ├── answer-section.tsx ├── app-sidebar.tsx ├── artifact │ ├── artifact-content.tsx │ ├── artifact-context.tsx │ ├── artifact-root.tsx │ ├── chat-artifact-container.tsx │ ├── reasoning-content.tsx │ ├── retrieve-artifact-content.tsx │ ├── search-artifact-content.tsx │ ├── tool-invocation-content.tsx │ └── video-search-artifact-content.tsx ├── chat-messages.tsx ├── chat-panel.tsx ├── chat-share.tsx ├── chat.tsx ├── clear-history.tsx ├── collapsible-message.tsx ├── current-user-avatar.tsx ├── custom-link.tsx ├── default-skeleton.tsx ├── empty-screen.tsx ├── external-link-items.tsx ├── forgot-password-form.tsx ├── guest-menu.tsx ├── header.tsx ├── inspector │ ├── inspector-drawer.tsx │ └── inspector-panel.tsx ├── login-form.tsx ├── message-actions.tsx ├── message.tsx ├── model-selector.tsx ├── question-confirmation.tsx ├── reasoning-section.tsx ├── related-questions.tsx ├── render-message.tsx ├── retrieve-section.tsx ├── retry-button.tsx ├── search-mode-toggle.tsx ├── search-results-image.tsx ├── search-results.tsx ├── search-section.tsx ├── section.tsx ├── sidebar │ ├── chat-history-client.tsx │ ├── chat-history-section.tsx │ ├── chat-history-skeleton.tsx │ ├── chat-menu-item.tsx │ └── clear-history-action.tsx ├── sign-up-form.tsx ├── theme-menu-items.tsx ├── theme-provider.tsx ├── tool-badge.tsx ├── tool-section.tsx ├── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── checkbox.tsx │ ├── codeblock.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── icons.tsx │ ├── index.ts │ ├── input.tsx │ ├── label.tsx │ ├── markdown.tsx │ ├── popover.tsx │ ├── resizable.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── spinner.tsx │ ├── status-indicator.tsx │ ├── switch.tsx │ ├── textarea.tsx │ ├── toggle.tsx │ ├── tooltip-button.tsx │ └── tooltip.tsx ├── update-password-form.tsx ├── user-menu.tsx ├── user-message.tsx ├── video-carousel-dialog.tsx ├── video-result-grid.tsx ├── video-search-results.tsx └── video-search-section.tsx ├── docker-compose.yaml ├── docs └── CONFIGURATION.md ├── hooks ├── use-current-user-image.ts ├── use-current-user-name.ts └── use-mobile.tsx ├── lib ├── actions │ └── chat.ts ├── agents │ ├── generate-related-questions.ts │ ├── manual-researcher.ts │ └── researcher.ts ├── auth │ └── get-current-user.ts ├── config │ ├── default-models.json │ └── models.ts ├── constants │ └── index.ts ├── hooks │ ├── use-copy-to-clipboard.ts │ └── use-media-query.ts ├── redis │ └── config.ts ├── schema │ ├── question.ts │ ├── related.tsx │ ├── retrieve.tsx │ └── search.tsx ├── streaming │ ├── create-manual-tool-stream.ts │ ├── create-tool-calling-stream.ts │ ├── handle-stream-finish.ts │ ├── parse-tool-call.ts │ ├── tool-execution.ts │ └── types.ts ├── supabase │ ├── client.ts │ ├── middleware.ts │ └── server.ts ├── tools │ ├── question.ts │ ├── retrieve.ts │ ├── search.ts │ ├── search │ │ └── providers │ │ │ ├── base.ts │ │ │ ├── exa.ts │ │ │ ├── index.ts │ │ │ ├── searxng.ts │ │ │ └── tavily.ts │ └── video-search.ts ├── types │ ├── index.ts │ └── models.ts └── utils │ ├── context-window.ts │ ├── cookies.ts │ ├── index.ts │ ├── registry.ts │ └── url.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── prettier.config.js ├── public ├── config │ └── models.json ├── images │ └── placeholder-image.png ├── providers │ └── logos │ │ ├── anthropic.svg │ │ ├── azure.svg │ │ ├── deepseek.svg │ │ ├── fireworks.svg │ │ ├── google.svg │ │ ├── groq.svg │ │ ├── ollama.svg │ │ ├── openai-compatible.svg │ │ ├── openai.svg │ │ └── xai.svg └── screenshot-2025-05-04.png ├── searxng-limiter.toml ├── searxng-settings.yml ├── tailwind.config.ts └── tsconfig.json /.cursor/rules/create-migration.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | # Specify the following for Cursor rules 3 | description: Guidelines for writing Postgres migrations 4 | alwaysApply: false 5 | --- 6 | 7 | # Database: Create migration 8 | 9 | You are a Postgres Expert who loves creating secure database schemas. 10 | 11 | This project uses the migrations provided by the Supabase CLI. 12 | 13 | ## Creating a migration file 14 | 15 | Given the context of the user's message, create a database migration file inside the folder `supabase/migrations/`. 16 | 17 | The file MUST following this naming convention: 18 | 19 | The file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time: 20 | 21 | 1. `YYYY` - Four digits for the year (e.g., `2024`). 22 | 2. `MM` - Two digits for the month (01 to 12). 23 | 3. `DD` - Two digits for the day of the month (01 to 31). 24 | 4. `HH` - Two digits for the hour in 24-hour format (00 to 23). 25 | 5. `mm` - Two digits for the minute (00 to 59). 26 | 6. `ss` - Two digits for the second (00 to 59). 27 | 7. Add an appropriate description for the migration. 28 | 29 | For example: 30 | 31 | ``` 32 | 20240906123045_create_profiles.sql 33 | ``` 34 | 35 | ## SQL Guidelines 36 | 37 | Write Postgres-compatible SQL code for Supabase migration files that: 38 | 39 | - Includes a header comment with metadata about the migration, such as the purpose, affected tables/columns, and any special considerations. 40 | - Includes thorough comments explaining the purpose and expected behavior of each migration step. 41 | - Write all SQL in lowercase. 42 | - Add copious comments for any destructive SQL commands, including truncating, dropping, or column alterations. 43 | - When creating a new table, you MUST enable Row Level Security (RLS) even if the table is intended for public access. 44 | - When creating RLS Policies 45 | - Ensure the policies cover all relevant access scenarios (e.g. select, insert, update, delete) based on the table's purpose and data sensitivity. 46 | - If the table is intended for public access the policy can simply return `true`. 47 | - RLS Policies should be granular: one policy for `select`, one for `insert` etc) and for each supabase role (`anon` and `authenticated`). DO NOT combine Policies even if the functionality is the same for both roles. 48 | - Include comments explaining the rationale and intended behavior of each security policy 49 | 50 | The generated SQL code should be production-ready, well-documented, and aligned with Supabase's best practices. 51 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: File a bug/issue 3 | title: '[BUG] ' 4 | labels: ['Bug', 'Needs Triage'] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Vercel Runtime Logs 16 | description: If this is a Vercel environment issue, have you checked the Vercel Runtime Logs? (https://vercel.com/docs/observability/runtime-logs) 17 | options: 18 | - label: I have checked the Vercel Runtime Logs for errors (if applicable) 19 | required: false 20 | - type: textarea 21 | attributes: 22 | label: Current Behavior 23 | description: A concise description of what you're experiencing. 24 | validations: 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: Expected Behavior 29 | description: A concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Steps To Reproduce 35 | description: Steps to reproduce the behavior. 36 | placeholder: | 37 | 1. In this environment... 38 | 2. With this config... 39 | 3. Run '...' 40 | 4. See error... 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Environment 46 | description: | 47 | examples: 48 | - Browser: Chrome 52.0.2743.116 49 | value: | 50 | - OS: 51 | - Browser: 52 | render: markdown 53 | validations: 54 | required: true 55 | - type: textarea 56 | attributes: 57 | label: Anything else? 58 | description: | 59 | Links? References? Anything that will give us more context about the issue you are encountering! 60 | 61 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 62 | validations: 63 | required: false 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Propose a new feature for Morphic. 3 | labels: [] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This template is to propose new features for Morphic. Please fill out the following information to help us understand your feature request. 9 | - type: textarea 10 | attributes: 11 | label: Feature Description 12 | description: A detailed description of the feature you are proposing for Morphic. Include any relevant technical details. 13 | placeholder: | 14 | Feature description... 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Use Case 20 | description: Provide a use case where this feature would be beneficial 21 | placeholder: | 22 | Use case... 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Any extra information that might help us understand your feature request. 30 | placeholder: | 31 | Additional context... 32 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Login to GitHub Container Registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Build and push Docker image 32 | uses: docker/build-push-action@v5 33 | with: 34 | context: . 35 | platforms: linux/amd64,linux/arm64 36 | push: true 37 | tags: | 38 | ghcr.io/${{ github.repository }}:latest 39 | ghcr.io/${{ github.repository }}:${{ github.sha }} 40 | cache-from: type=gha 41 | cache-to: type=gha,mode=max 42 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # cursor 39 | .cursor/rules/private -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "cSpell.words": ["openai", "Tavily"], 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "always" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all community spaces, and also applies when 49 | an individual is officially representing the community in public spaces. 50 | 51 | ## Enforcement 52 | 53 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 54 | reported to the community leaders responsible for enforcement at 55 | [INSERT CONTACT METHOD]. 56 | All complaints will be reviewed and investigated promptly and fairly. 57 | 58 | ## Attribution 59 | 60 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 61 | version 2.0, available at 62 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 63 | 64 | [homepage]: https://www.contributor-covenant.org 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Morphic 2 | 3 | Thank you for your interest in contributing to Morphic! This document provides guidelines and instructions for contributing. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). 8 | 9 | ## How to Contribute 10 | 11 | ### Reporting Issues 12 | 13 | - Check if the issue already exists in our [GitHub Issues](https://github.com/miurla/morphic/issues) 14 | - Use the issue templates when creating a new issue 15 | - Provide as much context as possible 16 | 17 | ### Pull Requests 18 | 19 | 1. Fork the repository 20 | 2. Create a new branch from `main`: 21 | ```bash 22 | git checkout -b feat/your-feature-name 23 | ``` 24 | 3. Make your changes 25 | 4. Commit your changes using conventional commits: 26 | ```bash 27 | git commit -m "feat: add new feature" 28 | ``` 29 | 5. Push to your fork 30 | 6. Open a Pull Request 31 | 32 | ### Commit Convention 33 | 34 | We use conventional commits. Examples: 35 | 36 | - `feat: add new feature` 37 | - `fix: resolve issue with X` 38 | - `docs: update README` 39 | - `chore: update dependencies` 40 | - `refactor: improve code structure` 41 | 42 | ### Development Setup 43 | 44 | Follow the [Quickstart](README.md#-quickstart) guide in the README to set up your development environment. 45 | 46 | ## License 47 | 48 | By contributing, you agree that your contributions will be licensed under the Apache-2.0 License. 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base image 2 | FROM oven/bun:1.2.12 AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Install dependencies (separated for better cache utilization) 7 | COPY package.json bun.lock ./ 8 | RUN bun install 9 | 10 | # Copy source code and build 11 | COPY . . 12 | RUN bun next telemetry disable 13 | RUN bun run build 14 | 15 | # Runtime stage 16 | FROM oven/bun:1.2.12 AS runner 17 | WORKDIR /app 18 | 19 | # Copy only necessary files from builder 20 | COPY --from=builder /app/.next ./.next 21 | COPY --from=builder /app/public ./public 22 | COPY --from=builder /app/package.json ./package.json 23 | COPY --from=builder /app/bun.lock ./bun.lock 24 | COPY --from=builder /app/node_modules ./node_modules 25 | 26 | # Start production server 27 | CMD ["bun", "start", "-H", "0.0.0.0"] 28 | -------------------------------------------------------------------------------- /app/api/chat/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { deleteChat } from '@/lib/actions/chat' 2 | import { getCurrentUserId } from '@/lib/auth/get-current-user' 3 | import { NextRequest, NextResponse } from 'next/server' 4 | 5 | export async function DELETE( 6 | request: NextRequest, 7 | { params }: { params: Promise<{ id: string }> } 8 | ) { 9 | const enableSaveChatHistory = process.env.ENABLE_SAVE_CHAT_HISTORY === 'true' 10 | if (!enableSaveChatHistory) { 11 | return NextResponse.json( 12 | { error: 'Chat history saving is disabled.' }, 13 | { status: 403 } 14 | ) 15 | } 16 | 17 | const chatId = (await params).id 18 | if (!chatId) { 19 | return NextResponse.json({ error: 'Chat ID is required' }, { status: 400 }) 20 | } 21 | 22 | const userId = await getCurrentUserId() 23 | 24 | try { 25 | const result = await deleteChat(chatId, userId) 26 | 27 | if (result.error) { 28 | const statusCode = result.error === 'Chat not found' ? 404 : 500 29 | return NextResponse.json({ error: result.error }, { status: statusCode }) 30 | } 31 | 32 | return NextResponse.json({ ok: true }) 33 | } catch (error) { 34 | console.error(`API route error deleting chat ${chatId}:`, error) 35 | return NextResponse.json( 36 | { error: 'Internal Server Error' }, 37 | { status: 500 } 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentUserId } from '@/lib/auth/get-current-user' 2 | import { createManualToolStreamResponse } from '@/lib/streaming/create-manual-tool-stream' 3 | import { createToolCallingStreamResponse } from '@/lib/streaming/create-tool-calling-stream' 4 | import { Model } from '@/lib/types/models' 5 | import { isProviderEnabled } from '@/lib/utils/registry' 6 | import { cookies } from 'next/headers' 7 | 8 | export const maxDuration = 30 9 | 10 | const DEFAULT_MODEL: Model = { 11 | id: 'gpt-4o-mini', 12 | name: 'GPT-4o mini', 13 | provider: 'OpenAI', 14 | providerId: 'openai', 15 | enabled: true, 16 | toolCallType: 'native' 17 | } 18 | 19 | export async function POST(req: Request) { 20 | try { 21 | const { messages, id: chatId } = await req.json() 22 | const referer = req.headers.get('referer') 23 | const isSharePage = referer?.includes('/share/') 24 | const userId = await getCurrentUserId() 25 | 26 | if (isSharePage) { 27 | return new Response('Chat API is not available on share pages', { 28 | status: 403, 29 | statusText: 'Forbidden' 30 | }) 31 | } 32 | 33 | const cookieStore = await cookies() 34 | const modelJson = cookieStore.get('selectedModel')?.value 35 | const searchMode = cookieStore.get('search-mode')?.value === 'true' 36 | 37 | let selectedModel = DEFAULT_MODEL 38 | 39 | if (modelJson) { 40 | try { 41 | selectedModel = JSON.parse(modelJson) as Model 42 | } catch (e) { 43 | console.error('Failed to parse selected model:', e) 44 | } 45 | } 46 | 47 | if ( 48 | !isProviderEnabled(selectedModel.providerId) || 49 | selectedModel.enabled === false 50 | ) { 51 | return new Response( 52 | `Selected provider is not enabled ${selectedModel.providerId}`, 53 | { 54 | status: 404, 55 | statusText: 'Not Found' 56 | } 57 | ) 58 | } 59 | 60 | const supportsToolCalling = selectedModel.toolCallType === 'native' 61 | 62 | return supportsToolCalling 63 | ? createToolCallingStreamResponse({ 64 | messages, 65 | model: selectedModel, 66 | chatId, 67 | searchMode, 68 | userId 69 | }) 70 | : createManualToolStreamResponse({ 71 | messages, 72 | model: selectedModel, 73 | chatId, 74 | searchMode, 75 | userId 76 | }) 77 | } catch (error) { 78 | console.error('API route error:', error) 79 | return new Response('Error processing your request', { 80 | status: 500, 81 | statusText: 'Internal Server Error' 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/api/chats/route.ts: -------------------------------------------------------------------------------- 1 | import { getChatsPage } from '@/lib/actions/chat' 2 | import { getCurrentUserId } from '@/lib/auth/get-current-user' 3 | import { type Chat } from '@/lib/types' 4 | import { NextRequest, NextResponse } from 'next/server' 5 | 6 | interface ChatPageResponse { 7 | chats: Chat[] 8 | nextOffset: number | null 9 | } 10 | 11 | export async function GET(request: NextRequest) { 12 | const enableSaveChatHistory = process.env.ENABLE_SAVE_CHAT_HISTORY === 'true' 13 | if (!enableSaveChatHistory) { 14 | return NextResponse.json<ChatPageResponse>({ chats: [], nextOffset: null }) 15 | } 16 | 17 | const { searchParams } = new URL(request.url) 18 | const offset = parseInt(searchParams.get('offset') || '0', 10) 19 | const limit = parseInt(searchParams.get('limit') || '20', 10) 20 | 21 | const userId = await getCurrentUserId() 22 | 23 | try { 24 | const result = await getChatsPage(userId, limit, offset) 25 | return NextResponse.json<ChatPageResponse>(result) 26 | } catch (error) { 27 | console.error('API route error fetching chats:', error) 28 | return NextResponse.json<ChatPageResponse>( 29 | { chats: [], nextOffset: null }, 30 | { status: 500 } 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/auth/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@/lib/supabase/server' 2 | import { type EmailOtpType } from '@supabase/supabase-js' 3 | import { redirect } from 'next/navigation' 4 | import { type NextRequest } from 'next/server' 5 | 6 | export async function GET(request: NextRequest) { 7 | const { searchParams } = new URL(request.url) 8 | const token_hash = searchParams.get('token_hash') 9 | const type = searchParams.get('type') as EmailOtpType | null 10 | const next = searchParams.get('next') ?? '/' 11 | 12 | if (token_hash && type) { 13 | const supabase = await createClient() 14 | 15 | const { error } = await supabase.auth.verifyOtp({ 16 | type, 17 | token_hash, 18 | }) 19 | if (!error) { 20 | // redirect user to specified redirect URL or root of app 21 | redirect(next) 22 | } else { 23 | // redirect the user to an error page with some instructions 24 | redirect(`/auth/error?error=${error?.message}`) 25 | } 26 | } 27 | 28 | // redirect the user to an error page with some instructions 29 | redirect(`/auth/error?error=No token hash or type`) 30 | } 31 | -------------------------------------------------------------------------------- /app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 2 | 3 | export default async function Page({ searchParams }: { searchParams: Promise<{ error: string }> }) { 4 | const params = await searchParams 5 | 6 | return ( 7 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 8 | <div className="w-full max-w-sm"> 9 | <div className="flex flex-col gap-6"> 10 | <Card> 11 | <CardHeader> 12 | <CardTitle className="text-2xl">Sorry, something went wrong.</CardTitle> 13 | </CardHeader> 14 | <CardContent> 15 | {params?.error ? ( 16 | <p className="text-sm text-muted-foreground">Code error: {params.error}</p> 17 | ) : ( 18 | <p className="text-sm text-muted-foreground">An unspecified error occurred.</p> 19 | )} 20 | </CardContent> 21 | </Card> 22 | </div> 23 | </div> 24 | </div> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/auth/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPasswordForm } from '@/components/forgot-password-form' 2 | 3 | export default function Page() { 4 | return ( 5 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 6 | <div className="w-full max-w-sm"> 7 | <ForgotPasswordForm /> 8 | </div> 9 | </div> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from '@/components/login-form' 2 | 3 | export default function Page() { 4 | return ( 5 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 6 | <div className="w-full max-w-sm"> 7 | <LoginForm /> 8 | </div> 9 | </div> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/auth/oauth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | // The client you created from the Server-Side Auth instructions 3 | import { createClient } from '@/lib/supabase/server' 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams, origin } = new URL(request.url) 7 | const code = searchParams.get('code') 8 | // if "next" is in param, use it as the redirect URL 9 | const next = searchParams.get('next') ?? '/' 10 | 11 | if (code) { 12 | const supabase = await createClient() 13 | const { error } = await supabase.auth.exchangeCodeForSession(code) 14 | if (!error) { 15 | const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer 16 | const isLocalEnv = process.env.NODE_ENV === 'development' 17 | if (isLocalEnv) { 18 | // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host 19 | return NextResponse.redirect(`${origin}${next}`) 20 | } else if (forwardedHost) { 21 | return NextResponse.redirect(`https://${forwardedHost}${next}`) 22 | } else { 23 | return NextResponse.redirect(`${origin}${next}`) 24 | } 25 | } 26 | } 27 | 28 | // return the user to an error page with instructions 29 | return NextResponse.redirect(`${origin}/auth/error`) 30 | } 31 | -------------------------------------------------------------------------------- /app/auth/sign-up-success/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from '@/components/ui/card' 8 | 9 | export default function Page() { 10 | return ( 11 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 12 | <div className="w-full max-w-sm"> 13 | <div className="flex flex-col gap-6"> 14 | <Card> 15 | <CardHeader> 16 | <CardTitle className="text-2xl">Thank you for signing up!</CardTitle> 17 | <CardDescription>Check your email to confirm</CardDescription> 18 | </CardHeader> 19 | <CardContent> 20 | <p className="text-sm text-muted-foreground"> 21 | You've successfully signed up. Please check your email to confirm your account 22 | before signing in. 23 | </p> 24 | </CardContent> 25 | </Card> 26 | </div> 27 | </div> 28 | </div> 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/auth/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUpForm } from '@/components/sign-up-form' 2 | 3 | export default function Page() { 4 | return ( 5 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 6 | <div className="w-full max-w-sm"> 7 | <SignUpForm /> 8 | </div> 9 | </div> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/auth/update-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { UpdatePasswordForm } from '@/components/update-password-form' 2 | 3 | export default function Page() { 4 | return ( 5 | <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> 6 | <div className="w-full max-w-sm"> 7 | <UpdatePasswordForm /> 8 | </div> 9 | </div> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miurla/morphic/4800cae1f6882c2dd95a050536cfba59fee05b0a/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 96.1%; 11 | --card-foreground: 0 0% 45.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 89.8%; 34 | 35 | --radius: 0.5rem; 36 | 37 | --chart-1: 12 76% 61%; 38 | 39 | --chart-2: 173 58% 39%; 40 | 41 | --chart-3: 197 37% 24%; 42 | 43 | --chart-4: 43 74% 66%; 44 | 45 | --chart-5: 27 87% 67%; 46 | 47 | --accent-blue: 210 100% 97%; 48 | --accent-blue-foreground: 210 100% 50%; 49 | --accent-blue-border: 210 100% 90%; 50 | --sidebar-background: 0 0% 98%; 51 | --sidebar-foreground: 240 5.3% 26.1%; 52 | --sidebar-primary: 240 5.9% 10%; 53 | --sidebar-primary-foreground: 0 0% 98%; 54 | --sidebar-accent: 240 4.8% 95.9%; 55 | --sidebar-accent-foreground: 240 5.9% 10%; 56 | --sidebar-border: 220 13% 91%; 57 | --sidebar-ring: 217.2 91.2% 59.8%; 58 | } 59 | 60 | .dark { 61 | --background: 0 0% 3.9%; 62 | --foreground: 0 0% 98%; 63 | 64 | --card: 0 0% 14.9%; 65 | --card-foreground: 0 0% 63.9%; 66 | 67 | --popover: 0 0% 3.9%; 68 | --popover-foreground: 0 0% 98%; 69 | 70 | --primary: 0 0% 98%; 71 | --primary-foreground: 0 0% 9%; 72 | 73 | --secondary: 0 0% 14.9%; 74 | --secondary-foreground: 0 0% 98%; 75 | 76 | --muted: 0 0% 14.9%; 77 | --muted-foreground: 0 0% 63.9%; 78 | 79 | --accent: 0 0% 14.9%; 80 | --accent-foreground: 0 0% 98%; 81 | 82 | --destructive: 0 62.8% 30.6%; 83 | --destructive-foreground: 0 0% 98%; 84 | 85 | --border: 0 0% 14.9%; 86 | --input: 0 0% 14.9%; 87 | --ring: 0 0% 14.9%; 88 | --chart-1: 220 70% 50%; 89 | --chart-2: 160 60% 45%; 90 | --chart-3: 30 80% 55%; 91 | --chart-4: 280 65% 60%; 92 | --chart-5: 340 75% 55%; 93 | 94 | --accent-blue: 210 100% 10%; 95 | --accent-blue-foreground: 210 100% 80%; 96 | --accent-blue-border: 210 100% 25%; 97 | --sidebar-background: 240 5.9% 10%; 98 | --sidebar-foreground: 240 4.8% 95.9%; 99 | --sidebar-primary: 224.3 76.3% 48%; 100 | --sidebar-primary-foreground: 0 0% 100%; 101 | --sidebar-accent: 240 3.7% 15.9%; 102 | --sidebar-accent-foreground: 240 4.8% 95.9%; 103 | --sidebar-border: 240 3.7% 15.9%; 104 | --sidebar-ring: 217.2 91.2% 59.8%; 105 | } 106 | } 107 | 108 | @layer base { 109 | * { 110 | @apply border-border; 111 | } 112 | body { 113 | @apply bg-background text-foreground; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import AppSidebar from '@/components/app-sidebar' 2 | import ArtifactRoot from '@/components/artifact/artifact-root' 3 | import Header from '@/components/header' 4 | import { ThemeProvider } from '@/components/theme-provider' 5 | import { SidebarProvider } from '@/components/ui/sidebar' 6 | import { Toaster } from '@/components/ui/sonner' 7 | import { createClient } from '@/lib/supabase/server' 8 | import { cn } from '@/lib/utils' 9 | import { Analytics } from '@vercel/analytics/next' 10 | import type { Metadata, Viewport } from 'next' 11 | import { Inter as FontSans } from 'next/font/google' 12 | import './globals.css' 13 | 14 | const fontSans = FontSans({ 15 | subsets: ['latin'], 16 | variable: '--font-sans' 17 | }) 18 | 19 | const title = 'Morphic' 20 | const description = 21 | 'A fully open-source AI-powered answer engine with a generative UI.' 22 | 23 | export const metadata: Metadata = { 24 | metadataBase: new URL('https://morphic.sh'), 25 | title, 26 | description, 27 | openGraph: { 28 | title, 29 | description 30 | }, 31 | twitter: { 32 | title, 33 | description, 34 | card: 'summary_large_image', 35 | creator: '@miiura' 36 | } 37 | } 38 | 39 | export const viewport: Viewport = { 40 | width: 'device-width', 41 | initialScale: 1, 42 | minimumScale: 1, 43 | maximumScale: 1 44 | } 45 | 46 | export default async function RootLayout({ 47 | children 48 | }: Readonly<{ 49 | children: React.ReactNode 50 | }>) { 51 | let user = null 52 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL 53 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY 54 | 55 | if (supabaseUrl && supabaseAnonKey) { 56 | const supabase = await createClient() 57 | const { 58 | data: { user: supabaseUser } 59 | } = await supabase.auth.getUser() 60 | user = supabaseUser 61 | } 62 | 63 | return ( 64 | <html lang="en" suppressHydrationWarning> 65 | <body 66 | className={cn( 67 | 'min-h-screen flex flex-col font-sans antialiased', 68 | fontSans.variable 69 | )} 70 | > 71 | <ThemeProvider 72 | attribute="class" 73 | defaultTheme="system" 74 | enableSystem 75 | disableTransitionOnChange 76 | > 77 | <SidebarProvider defaultOpen> 78 | <AppSidebar /> 79 | <div className="flex flex-col flex-1"> 80 | <Header user={user} /> 81 | <main className="flex flex-1 min-h-0"> 82 | <ArtifactRoot>{children}</ArtifactRoot> 83 | </main> 84 | </div> 85 | </SidebarProvider> 86 | <Toaster /> 87 | <Analytics /> 88 | </ThemeProvider> 89 | </body> 90 | </html> 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miurla/morphic/4800cae1f6882c2dd95a050536cfba59fee05b0a/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from '@/components/chat' 2 | import { getModels } from '@/lib/config/models' 3 | import { generateId } from 'ai' 4 | 5 | export default async function Page() { 6 | const id = generateId() 7 | const models = await getModels() 8 | return <Chat id={id} models={models} /> 9 | } 10 | -------------------------------------------------------------------------------- /app/search/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from '@/components/chat' 2 | import { getChat } from '@/lib/actions/chat' 3 | import { getCurrentUserId } from '@/lib/auth/get-current-user' 4 | import { getModels } from '@/lib/config/models' 5 | import { convertToUIMessages } from '@/lib/utils' 6 | import { notFound, redirect } from 'next/navigation' 7 | 8 | export const maxDuration = 60 9 | 10 | export async function generateMetadata(props: { 11 | params: Promise<{ id: string }> 12 | }) { 13 | const { id } = await props.params 14 | const userId = await getCurrentUserId() 15 | const chat = await getChat(id, userId) 16 | return { 17 | title: chat?.title.toString().slice(0, 50) || 'Search' 18 | } 19 | } 20 | 21 | export default async function SearchPage(props: { 22 | params: Promise<{ id: string }> 23 | }) { 24 | const userId = await getCurrentUserId() 25 | const { id } = await props.params 26 | 27 | const chat = await getChat(id, userId) 28 | // convertToUIMessages for useChat hook 29 | const messages = convertToUIMessages(chat?.messages || []) 30 | 31 | if (!chat) { 32 | redirect('/') 33 | } 34 | 35 | if (chat?.userId !== userId && chat?.userId !== 'anonymous') { 36 | notFound() 37 | } 38 | 39 | const models = await getModels() 40 | return <Chat id={id} savedMessages={messages} models={models} /> 41 | } 42 | -------------------------------------------------------------------------------- /app/search/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DefaultSkeleton } from '../../components/default-skeleton' 4 | 5 | export default function Loading() { 6 | return ( 7 | <div className="flex flex-col flex-1 items-center justify-center"> 8 | <div className="w-full max-w-3xl px-4"> 9 | <DefaultSkeleton /> 10 | </div> 11 | </div> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from '@/components/chat' 2 | import { getModels } from '@/lib/config/models' 3 | import { generateId } from 'ai' 4 | import { redirect } from 'next/navigation' 5 | 6 | export const maxDuration = 60 7 | 8 | export default async function SearchPage(props: { 9 | searchParams: Promise<{ q: string }> 10 | }) { 11 | const { q } = await props.searchParams 12 | if (!q) { 13 | redirect('/') 14 | } 15 | 16 | const id = generateId() 17 | const models = await getModels() 18 | return <Chat id={id} query={q} models={models} /> 19 | } 20 | -------------------------------------------------------------------------------- /app/share/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from '@/components/chat' 2 | import { getSharedChat } from '@/lib/actions/chat' 3 | import { getModels } from '@/lib/config/models' 4 | import { convertToUIMessages } from '@/lib/utils' 5 | import { notFound } from 'next/navigation' 6 | 7 | export async function generateMetadata(props: { 8 | params: Promise<{ id: string }> 9 | }) { 10 | const { id } = await props.params 11 | const chat = await getSharedChat(id) 12 | 13 | if (!chat || !chat.sharePath) { 14 | return notFound() 15 | } 16 | 17 | return { 18 | title: chat?.title.toString().slice(0, 50) || 'Search' 19 | } 20 | } 21 | 22 | export default async function SharePage(props: { 23 | params: Promise<{ id: string }> 24 | }) { 25 | const { id } = await props.params 26 | const chat = await getSharedChat(id) 27 | 28 | if (!chat || !chat.sharePath) { 29 | return notFound() 30 | } 31 | 32 | const models = await getModels() 33 | return ( 34 | <Chat 35 | id={chat.id} 36 | savedMessages={convertToUIMessages(chat.messages)} 37 | models={models} 38 | /> 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /app/share/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DefaultSkeleton } from '../../components/default-skeleton' 4 | 5 | export default function Loading() { 6 | return ( 7 | <div className="flex flex-col flex-1 items-center justify-center"> 8 | <div className="w-full max-w-3xl px-4"> 9 | <DefaultSkeleton /> 10 | </div> 11 | </div> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 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 | } -------------------------------------------------------------------------------- /components/answer-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChatRequestOptions } from 'ai' 4 | import { CollapsibleMessage } from './collapsible-message' 5 | import { DefaultSkeleton } from './default-skeleton' 6 | import { BotMessage } from './message' 7 | import { MessageActions } from './message-actions' 8 | 9 | export type AnswerSectionProps = { 10 | content: string 11 | isOpen: boolean 12 | onOpenChange: (open: boolean) => void 13 | chatId?: string 14 | showActions?: boolean 15 | messageId: string 16 | reload?: ( 17 | messageId: string, 18 | options?: ChatRequestOptions 19 | ) => Promise<string | null | undefined> 20 | } 21 | 22 | export function AnswerSection({ 23 | content, 24 | isOpen, 25 | onOpenChange, 26 | chatId, 27 | showActions = true, // Default to true for backward compatibility 28 | messageId, 29 | reload 30 | }: AnswerSectionProps) { 31 | const enableShare = process.env.NEXT_PUBLIC_ENABLE_SHARE === 'true' 32 | 33 | const handleReload = () => { 34 | if (reload) { 35 | return reload(messageId) 36 | } 37 | return Promise.resolve(undefined) 38 | } 39 | 40 | const message = content ? ( 41 | <div className="flex flex-col gap-1"> 42 | <BotMessage message={content} /> 43 | {showActions && ( 44 | <MessageActions 45 | message={content} // Keep original message content for copy 46 | messageId={messageId} 47 | chatId={chatId} 48 | enableShare={enableShare} 49 | reload={handleReload} 50 | /> 51 | )} 52 | </div> 53 | ) : ( 54 | <DefaultSkeleton /> 55 | ) 56 | return ( 57 | <CollapsibleMessage 58 | role="assistant" 59 | isCollapsible={false} 60 | isOpen={isOpen} 61 | onOpenChange={onOpenChange} 62 | showBorder={false} 63 | showIcon={false} 64 | > 65 | {message} 66 | </CollapsibleMessage> 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Sidebar, 3 | SidebarContent, 4 | SidebarHeader, 5 | SidebarMenu, 6 | SidebarMenuButton, 7 | SidebarMenuItem, 8 | SidebarRail, 9 | SidebarTrigger 10 | } from '@/components/ui/sidebar' 11 | import { cn } from '@/lib/utils' 12 | import { Plus } from 'lucide-react' 13 | import Link from 'next/link' 14 | import { Suspense } from 'react' 15 | import { ChatHistorySection } from './sidebar/chat-history-section' 16 | import { ChatHistorySkeleton } from './sidebar/chat-history-skeleton' 17 | import { IconLogo } from './ui/icons' 18 | 19 | export default function AppSidebar() { 20 | return ( 21 | <Sidebar side="left" variant="sidebar" collapsible="offcanvas"> 22 | <SidebarHeader className="flex flex-row justify-between items-center"> 23 | <Link href="/" className="flex items-center gap-2 px-2 py-3"> 24 | <IconLogo className={cn('size-5')} /> 25 | <span className="font-semibold text-sm">Morphic</span> 26 | </Link> 27 | <SidebarTrigger /> 28 | </SidebarHeader> 29 | <SidebarContent className="flex flex-col px-2 py-4 h-full"> 30 | <SidebarMenu> 31 | <SidebarMenuItem> 32 | <SidebarMenuButton asChild> 33 | <Link href="/" className="flex items-center gap-2"> 34 | <Plus className="size-4" /> 35 | <span>New</span> 36 | </Link> 37 | </SidebarMenuButton> 38 | </SidebarMenuItem> 39 | </SidebarMenu> 40 | <div className="flex-1 overflow-y-auto"> 41 | <Suspense fallback={<ChatHistorySkeleton />}> 42 | <ChatHistorySection /> 43 | </Suspense> 44 | </div> 45 | </SidebarContent> 46 | <SidebarRail /> 47 | </Sidebar> 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/artifact/artifact-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Part } from '@/components/artifact/artifact-context' 4 | import { ReasoningContent } from './reasoning-content' 5 | import { ToolInvocationContent } from './tool-invocation-content' 6 | 7 | export function ArtifactContent({ part }: { part: Part | null }) { 8 | if (!part) return null 9 | 10 | switch (part.type) { 11 | case 'tool-invocation': 12 | return <ToolInvocationContent toolInvocation={part.toolInvocation} /> 13 | case 'reasoning': 14 | return <ReasoningContent reasoning={part.reasoning} /> 15 | default: 16 | return ( 17 | <div className="p-4">Details for this part type are not available</div> 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /components/artifact/artifact-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ToolInvocation } from 'ai' 4 | import { 5 | createContext, 6 | ReactNode, 7 | useCallback, 8 | useContext, 9 | useEffect, 10 | useReducer 11 | } from 'react' 12 | import { useSidebar } from '../ui/sidebar' 13 | 14 | // Part types as seen in render-message.tsx 15 | export type TextPart = { 16 | type: 'text' 17 | text: string 18 | } 19 | 20 | export type ReasoningPart = { 21 | type: 'reasoning' 22 | reasoning: string 23 | } 24 | 25 | export type ToolInvocationPart = { 26 | type: 'tool-invocation' 27 | toolInvocation: ToolInvocation 28 | } 29 | 30 | export type Part = TextPart | ReasoningPart | ToolInvocationPart 31 | 32 | interface ArtifactState { 33 | part: Part | null 34 | isOpen: boolean 35 | } 36 | 37 | type ArtifactAction = { type: 'OPEN'; payload: Part } | { type: 'CLOSE' } 38 | 39 | const initialState: ArtifactState = { 40 | part: null, 41 | isOpen: false 42 | } 43 | 44 | function artifactReducer( 45 | state: ArtifactState, 46 | action: ArtifactAction 47 | ): ArtifactState { 48 | switch (action.type) { 49 | case 'OPEN': 50 | return { part: action.payload, isOpen: true } 51 | case 'CLOSE': 52 | return { ...state, isOpen: false } 53 | default: 54 | return state 55 | } 56 | } 57 | 58 | interface ArtifactContextValue { 59 | state: ArtifactState 60 | open: (part: Part) => void 61 | close: () => void 62 | } 63 | 64 | const ArtifactContext = createContext<ArtifactContextValue | undefined>( 65 | undefined 66 | ) 67 | 68 | export function ArtifactProvider({ children }: { children: ReactNode }) { 69 | const [state, dispatch] = useReducer(artifactReducer, initialState) 70 | const { setOpen, open: sidebarOpen } = useSidebar() 71 | 72 | const close = useCallback(() => { 73 | dispatch({ type: 'CLOSE' }) 74 | }, []) 75 | 76 | // Close artifact when sidebar opens 77 | useEffect(() => { 78 | if (sidebarOpen && state.isOpen) { 79 | close() 80 | } 81 | }, [sidebarOpen, state.isOpen, close]) 82 | 83 | const open = (part: Part) => { 84 | dispatch({ type: 'OPEN', payload: part }) 85 | setOpen(false) 86 | } 87 | 88 | return ( 89 | <ArtifactContext.Provider value={{ state, open, close }}> 90 | {children} 91 | </ArtifactContext.Provider> 92 | ) 93 | } 94 | 95 | export function useArtifact() { 96 | const context = useContext(ArtifactContext) 97 | if (context === undefined) { 98 | throw new Error('useArtifact must be used within an ArtifactProvider') 99 | } 100 | return context 101 | } 102 | -------------------------------------------------------------------------------- /components/artifact/artifact-root.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ReactNode } from 'react' 4 | import { ArtifactProvider } from './artifact-context' 5 | import { ChatArtifactContainer } from './chat-artifact-container' 6 | 7 | export default function ArtifactRoot({ children }: { children: ReactNode }) { 8 | return ( 9 | <ArtifactProvider> 10 | <ChatArtifactContainer>{children}</ChatArtifactContainer> 11 | </ArtifactProvider> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/artifact/chat-artifact-container.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { InspectorDrawer } from '@/components/inspector/inspector-drawer' 4 | import { InspectorPanel } from '@/components/inspector/inspector-panel' 5 | import { 6 | ResizableHandle, 7 | ResizablePanel, 8 | ResizablePanelGroup 9 | } from '@/components/ui/resizable' 10 | import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar' 11 | import { useMediaQuery } from '@/lib/hooks/use-media-query' 12 | import { cn } from '@/lib/utils' 13 | import React, { useEffect, useState } from 'react' 14 | import { useArtifact } from './artifact-context' 15 | export function ChatArtifactContainer({ 16 | children 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | const { state } = useArtifact() 21 | const isMobile = useMediaQuery('(max-width: 767px)') // Below md breakpoint 22 | const [renderPanel, setRenderPanel] = useState(state.isOpen) 23 | const { open, openMobile, isMobile: isMobileSidebar } = useSidebar() 24 | 25 | useEffect(() => { 26 | if (state.isOpen) { 27 | setRenderPanel(true) 28 | } else { 29 | setRenderPanel(false) 30 | } 31 | }, [state.isOpen]) 32 | 33 | return ( 34 | <div className="flex-1 min-h-0 h-screen flex"> 35 | <div className="absolute p-4 z-50 transition-opacity duration-1000"> 36 | {(!open || isMobileSidebar) && ( 37 | <SidebarTrigger className="animate-fade-in" /> 38 | )} 39 | </div> 40 | {/* Desktop: Resizable panels (Do not render on mobile) */} 41 | {!isMobile && ( 42 | <ResizablePanelGroup 43 | direction="horizontal" 44 | className="flex flex-1 min-w-0 h-full" // Responsive classes removed 45 | > 46 | <ResizablePanel 47 | className={cn( 48 | 'min-w-0', 49 | state.isOpen && 'transition-[flex-basis] duration-200 ease-out' 50 | )} 51 | > 52 | {children} 53 | </ResizablePanel> 54 | 55 | {renderPanel && ( 56 | <> 57 | <ResizableHandle /> 58 | <ResizablePanel 59 | className={cn('overflow-hidden', { 60 | 'animate-slide-in-right': state.isOpen 61 | })} 62 | maxSize={50} 63 | minSize={30} 64 | defaultSize={40} 65 | > 66 | <InspectorPanel /> 67 | </ResizablePanel> 68 | </> 69 | )} 70 | </ResizablePanelGroup> 71 | )} 72 | 73 | {/* Mobile: full-width chat + drawer (Do not render on desktop) */} 74 | {isMobile && ( 75 | <div className="flex-1 h-full"> 76 | {' '} 77 | {/* Responsive classes removed */} 78 | {children} 79 | {/* ArtifactDrawer checks isMobile internally, no double check needed */} 80 | <InspectorDrawer /> 81 | </div> 82 | )} 83 | </div> 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /components/artifact/reasoning-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import remarkGfm from 'remark-gfm' 5 | import { MemoizedReactMarkdown } from '../ui/markdown' 6 | 7 | export function ReasoningContent({ reasoning }: { reasoning: string }) { 8 | return ( 9 | <div className="p-4 overflow-auto"> 10 | <h3 className="text-lg font-semibold mb-2">Reasoning</h3> 11 | <div className={cn('prose prose-sm dark:prose-invert max-w-none')}> 12 | <MemoizedReactMarkdown remarkPlugins={[remarkGfm]}> 13 | {reasoning} 14 | </MemoizedReactMarkdown> 15 | </div> 16 | </div> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/artifact/retrieve-artifact-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SearchResults } from '@/components/search-results' 4 | import { Section, ToolArgsSection } from '@/components/section' 5 | import type { 6 | SearchResultItem, 7 | SearchResults as TypeSearchResults 8 | } from '@/lib/types/index' 9 | import type { ToolInvocation } from 'ai' 10 | import { MemoizedReactMarkdown } from '../ui/markdown' 11 | 12 | const MAX_CONTENT_LENGTH = 1000 13 | 14 | export function RetrieveArtifactContent({ tool }: { tool: ToolInvocation }) { 15 | const searchResults: TypeSearchResults | undefined = 16 | tool.state === 'result' ? tool.result : undefined 17 | const url = tool.args?.url as string | undefined 18 | 19 | if (!searchResults?.results) { 20 | return <div className="p-4">No retrieved content</div> 21 | } 22 | 23 | const truncatedResults: SearchResultItem[] = searchResults.results.map( 24 | result => ({ 25 | ...result, 26 | content: 27 | result.content.length > MAX_CONTENT_LENGTH 28 | ? `${result.content.substring(0, MAX_CONTENT_LENGTH)}...` 29 | : result.content 30 | }) 31 | ) 32 | 33 | return ( 34 | <div className="space-y-2"> 35 | <ToolArgsSection tool="retrieve">{url}</ToolArgsSection> 36 | 37 | <Section title="Sources"> 38 | <SearchResults results={truncatedResults} displayMode="list" /> 39 | </Section> 40 | {truncatedResults[0].content && ( 41 | <Section title="Content"> 42 | <MemoizedReactMarkdown className="prose-sm prose-neutral prose-p:text-sm text-muted-foreground"> 43 | {truncatedResults[0].content} 44 | </MemoizedReactMarkdown> 45 | </Section> 46 | )} 47 | </div> 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/artifact/search-artifact-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SearchResults } from '@/components/search-results' 4 | import { SearchResultsImageSection } from '@/components/search-results-image' 5 | import { Section, ToolArgsSection } from '@/components/section' 6 | import type { SearchResults as TypeSearchResults } from '@/lib/types' 7 | import type { ToolInvocation } from 'ai' 8 | 9 | export function SearchArtifactContent({ tool }: { tool: ToolInvocation }) { 10 | const searchResults: TypeSearchResults = 11 | tool.state === 'result' ? tool.result : undefined 12 | const query = tool.args?.query as string | undefined 13 | 14 | if (!searchResults?.results) { 15 | return <div className="p-4">No search results</div> 16 | } 17 | 18 | return ( 19 | <div className="space-y-2"> 20 | <ToolArgsSection tool="search">{`${query}`}</ToolArgsSection> 21 | {searchResults.images && searchResults.images.length > 0 && ( 22 | <SearchResultsImageSection 23 | images={searchResults.images} 24 | query={query} 25 | displayMode="full" 26 | /> 27 | )} 28 | 29 | <Section title="Sources"> 30 | <SearchResults results={searchResults.results} displayMode="list" /> 31 | </Section> 32 | </div> 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/artifact/tool-invocation-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { RetrieveArtifactContent } from '@/components/artifact/retrieve-artifact-content' 4 | import { SearchArtifactContent } from '@/components/artifact/search-artifact-content' 5 | import { VideoSearchArtifactContent } from '@/components/artifact/video-search-artifact-content' 6 | import type { ToolInvocation } from 'ai' 7 | 8 | export function ToolInvocationContent({ 9 | toolInvocation 10 | }: { 11 | toolInvocation: ToolInvocation 12 | }) { 13 | switch (toolInvocation.toolName) { 14 | case 'search': 15 | return <SearchArtifactContent tool={toolInvocation} /> 16 | case 'retrieve': 17 | return <RetrieveArtifactContent tool={toolInvocation} /> 18 | case 'videoSearch': 19 | return <VideoSearchArtifactContent tool={toolInvocation} /> 20 | default: 21 | return <div className="p-4">Details for this tool are not available</div> 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /components/artifact/video-search-artifact-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ToolArgsSection } from '@/components/section' 4 | import { VideoResultGrid } from '@/components/video-result-grid' 5 | import { 6 | type SerperSearchResultItem, 7 | type SerperSearchResults 8 | } from '@/lib/types' 9 | import type { ToolInvocation } from 'ai' 10 | 11 | export function VideoSearchArtifactContent({ tool }: { tool: ToolInvocation }) { 12 | const videoResults: SerperSearchResults | undefined = 13 | tool.state === 'result' ? tool.result : undefined 14 | const query = tool.args?.query as string | undefined 15 | 16 | const videos = (videoResults?.videos || []).filter( 17 | (video: SerperSearchResultItem) => { 18 | try { 19 | return new URL(video.link).pathname === '/watch' 20 | } catch (e) { 21 | console.error('Invalid video URL:', video.link) 22 | return false 23 | } 24 | } 25 | ) 26 | 27 | if (videos.length === 0) { 28 | return ( 29 | <div className="p-4 space-y-4"> 30 | <ToolArgsSection tool="videoSearch">{query}</ToolArgsSection> 31 | <p className="text-muted-foreground">No video results</p> 32 | </div> 33 | ) 34 | } 35 | 36 | return ( 37 | <div className="space-y-4 p-4"> 38 | <ToolArgsSection tool="videoSearch">{query}</ToolArgsSection> 39 | <VideoResultGrid 40 | videos={videos} 41 | query={query || ''} 42 | displayMode="artifact" 43 | /> 44 | </div> 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/chat-share.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { shareChat } from '@/lib/actions/chat' 4 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' 5 | import { cn } from '@/lib/utils' 6 | import { Share } from 'lucide-react' 7 | import { useState, useTransition } from 'react' 8 | import { toast } from 'sonner' 9 | import { Button } from './ui/button' 10 | import { 11 | Dialog, 12 | DialogContent, 13 | DialogDescription, 14 | DialogFooter, 15 | DialogHeader, 16 | DialogTitle, 17 | DialogTrigger 18 | } from './ui/dialog' 19 | import { Spinner } from './ui/spinner' 20 | 21 | interface ChatShareProps { 22 | chatId: string 23 | className?: string 24 | } 25 | 26 | export function ChatShare({ chatId, className }: ChatShareProps) { 27 | const [open, setOpen] = useState(false) 28 | const [pending, startTransition] = useTransition() 29 | const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) 30 | const [shareUrl, setShareUrl] = useState('') 31 | 32 | const handleShare = async () => { 33 | startTransition(() => { 34 | setOpen(true) 35 | }) 36 | const result = await shareChat(chatId) 37 | if (!result) { 38 | toast.error('Failed to share chat') 39 | return 40 | } 41 | 42 | if (!result.sharePath) { 43 | toast.error('Could not copy link to clipboard') 44 | return 45 | } 46 | 47 | const url = new URL(result.sharePath, window.location.href) 48 | setShareUrl(url.toString()) 49 | } 50 | 51 | const handleCopy = () => { 52 | if (shareUrl) { 53 | copyToClipboard(shareUrl) 54 | toast.success('Link copied to clipboard') 55 | setOpen(false) 56 | } else { 57 | toast.error('No link to copy') 58 | } 59 | } 60 | 61 | return ( 62 | <div className={className}> 63 | <Dialog 64 | open={open} 65 | onOpenChange={open => setOpen(open)} 66 | aria-labelledby="share-dialog-title" 67 | aria-describedby="share-dialog-description" 68 | > 69 | <DialogTrigger asChild> 70 | <Button 71 | className={cn('rounded-full')} 72 | size="icon" 73 | variant={'ghost'} 74 | onClick={() => setOpen(true)} 75 | > 76 | <Share size={14} /> 77 | </Button> 78 | </DialogTrigger> 79 | <DialogContent> 80 | <DialogHeader> 81 | <DialogTitle>Share link to search result</DialogTitle> 82 | <DialogDescription> 83 | Anyone with the link will be able to view this search result. 84 | </DialogDescription> 85 | </DialogHeader> 86 | <DialogFooter className="items-center"> 87 | {!shareUrl && ( 88 | <Button onClick={handleShare} disabled={pending} size="sm"> 89 | {pending ? <Spinner /> : 'Get link'} 90 | </Button> 91 | )} 92 | {shareUrl && ( 93 | <Button onClick={handleCopy} disabled={pending} size="sm"> 94 | {'Copy link'} 95 | </Button> 96 | )} 97 | </DialogFooter> 98 | </DialogContent> 99 | </Dialog> 100 | </div> 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /components/clear-history.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger 13 | } from '@/components/ui/alert-dialog' 14 | import { Button } from '@/components/ui/button' 15 | import { clearChats } from '@/lib/actions/chat' 16 | import { useState, useTransition } from 'react' 17 | import { toast } from 'sonner' 18 | import { Spinner } from './ui/spinner' 19 | 20 | type ClearHistoryProps = { 21 | empty: boolean 22 | } 23 | 24 | export function ClearHistory({ empty }: ClearHistoryProps) { 25 | const [open, setOpen] = useState(false) 26 | const [isPending, startTransition] = useTransition() 27 | return ( 28 | <AlertDialog open={open} onOpenChange={setOpen}> 29 | <AlertDialogTrigger asChild> 30 | <Button variant="outline" className="w-full" disabled={empty}> 31 | Clear History 32 | </Button> 33 | </AlertDialogTrigger> 34 | <AlertDialogContent> 35 | <AlertDialogHeader> 36 | <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 37 | <AlertDialogDescription> 38 | This action cannot be undone. This will permanently delete your 39 | history and remove your data from our servers. 40 | </AlertDialogDescription> 41 | </AlertDialogHeader> 42 | <AlertDialogFooter> 43 | <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> 44 | <AlertDialogAction 45 | disabled={isPending} 46 | onClick={event => { 47 | event.preventDefault() 48 | startTransition(async () => { 49 | const result = await clearChats() 50 | if (result?.error) { 51 | toast.error(result.error) 52 | } else { 53 | toast.success('History cleared') 54 | } 55 | setOpen(false) 56 | }) 57 | }} 58 | > 59 | {isPending ? <Spinner /> : 'Clear'} 60 | </AlertDialogAction> 61 | </AlertDialogFooter> 62 | </AlertDialogContent> 63 | </AlertDialog> 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /components/collapsible-message.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | import { ChevronDown } from 'lucide-react' 3 | import { CurrentUserAvatar } from './current-user-avatar' 4 | import { 5 | Collapsible, 6 | CollapsibleContent, 7 | CollapsibleTrigger 8 | } from './ui/collapsible' 9 | import { IconLogo } from './ui/icons' 10 | import { Separator } from './ui/separator' 11 | 12 | interface CollapsibleMessageProps { 13 | children: React.ReactNode 14 | role: 'user' | 'assistant' 15 | isCollapsible?: boolean 16 | isOpen?: boolean 17 | header?: React.ReactNode 18 | onOpenChange?: (open: boolean) => void 19 | showBorder?: boolean 20 | showIcon?: boolean 21 | } 22 | 23 | export function CollapsibleMessage({ 24 | children, 25 | role, 26 | isCollapsible = false, 27 | isOpen = true, 28 | header, 29 | onOpenChange, 30 | showBorder = true, 31 | showIcon = true 32 | }: CollapsibleMessageProps) { 33 | const content = <div className="flex-1">{children}</div> 34 | 35 | return ( 36 | <div className="flex"> 37 | {showIcon && ( 38 | <div className="relative flex flex-col items-center"> 39 | <div className="w-5"> 40 | {role === 'assistant' ? ( 41 | <IconLogo className="size-5" /> 42 | ) : ( 43 | <CurrentUserAvatar /> 44 | )} 45 | </div> 46 | </div> 47 | )} 48 | 49 | {isCollapsible ? ( 50 | <div 51 | className={cn( 52 | 'flex-1 rounded-2xl p-4', 53 | showBorder && 'border border-border/50' 54 | )} 55 | > 56 | <Collapsible 57 | open={isOpen} 58 | onOpenChange={onOpenChange} 59 | className="w-full" 60 | > 61 | <div className="flex items-center justify-between w-full gap-2"> 62 | {header && <div className="text-sm w-full">{header}</div>} 63 | <CollapsibleTrigger asChild> 64 | <button 65 | type="button" 66 | className="rounded-md p-1 hover:bg-accent group" 67 | aria-label={isOpen ? 'Collapse' : 'Expand'} 68 | > 69 | <ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" /> 70 | </button> 71 | </CollapsibleTrigger> 72 | </div> 73 | <CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down"> 74 | <Separator className="my-4 border-border/50" /> 75 | {content} 76 | </CollapsibleContent> 77 | </Collapsible> 78 | </div> 79 | ) : ( 80 | <div 81 | className={cn( 82 | 'flex-1 rounded-2xl', 83 | role === 'assistant' ? 'px-0' : 'px-3' 84 | )} 85 | > 86 | {content} 87 | </div> 88 | )} 89 | </div> 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /components/current-user-avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 4 | import { useCurrentUserImage } from '@/hooks/use-current-user-image' 5 | import { useCurrentUserName } from '@/hooks/use-current-user-name' 6 | import { User2 } from 'lucide-react' 7 | 8 | export const CurrentUserAvatar = () => { 9 | const profileImage = useCurrentUserImage() 10 | const name = useCurrentUserName() 11 | const initials = name 12 | ?.split(' ') 13 | ?.map(word => word[0]) 14 | ?.join('') 15 | ?.toUpperCase() 16 | 17 | return ( 18 | <Avatar className="size-6"> 19 | {profileImage && <AvatarImage src={profileImage} alt={initials} />} 20 | <AvatarFallback> 21 | {initials === '?' ? ( 22 | <User2 size={16} className="text-muted-foreground" /> 23 | ) : ( 24 | initials 25 | )} 26 | </AvatarFallback> 27 | </Avatar> 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/custom-link.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | import { AnchorHTMLAttributes, DetailedHTMLProps, ReactNode } from 'react' 3 | 4 | type CustomLinkProps = Omit< 5 | DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, 6 | 'ref' 7 | > & { 8 | children: ReactNode 9 | } 10 | 11 | export function Citing({ 12 | href, 13 | children, 14 | className, 15 | ...props 16 | }: CustomLinkProps) { 17 | const childrenText = children?.toString() || '' 18 | const isNumber = /^\d+$/.test(childrenText) 19 | const linkClasses = cn( 20 | isNumber 21 | ? 'text-[10px] bg-muted text-muted-froreground rounded-full w-4 h-4 px-0.5 inline-flex items-center justify-center hover:bg-muted/50 duration-200 no-underline -translate-y-0.5' 22 | : 'hover:underline inline-flex items-center gap-1.5', 23 | className 24 | ) 25 | 26 | return ( 27 | <a 28 | href={href} 29 | target="_blank" 30 | rel="noopener noreferrer" 31 | className={linkClasses} 32 | {...props} 33 | > 34 | {children} 35 | </a> 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/default-skeleton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Skeleton } from './ui/skeleton' 4 | 5 | export const DefaultSkeleton = () => { 6 | return ( 7 | <div className="flex flex-col gap-2 pb-4 pt-2"> 8 | <Skeleton className="h-6 w-48" /> 9 | <Skeleton className="w-full h-6" /> 10 | </div> 11 | ) 12 | } 13 | 14 | export function SearchSkeleton() { 15 | return ( 16 | <div className="flex flex-wrap gap-2 pb-0.5"> 17 | {[...Array(4)].map((_, index) => ( 18 | <div 19 | key={index} 20 | className="w-[calc(50%-0.5rem)] md:w-[calc(25%-0.5rem)]" 21 | > 22 | <Skeleton className="h-20 w-full" /> 23 | </div> 24 | ))} 25 | </div> 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/empty-screen.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { ArrowRight } from 'lucide-react' 3 | 4 | const exampleMessages = [ 5 | { 6 | heading: 'What is DeepSeek R1?', 7 | message: 'What is DeepSeek R1?' 8 | }, 9 | { 10 | heading: 'Why is Nvidia growing rapidly?', 11 | message: 'Why is Nvidia growing rapidly?' 12 | }, 13 | { 14 | heading: 'Tesla vs Rivian', 15 | message: 'Tesla vs Rivian' 16 | }, 17 | { 18 | heading: 'Summary: https://arxiv.org/pdf/2501.05707', 19 | message: 'Summary: https://arxiv.org/pdf/2501.05707' 20 | } 21 | ] 22 | export function EmptyScreen({ 23 | submitMessage, 24 | className 25 | }: { 26 | submitMessage: (message: string) => void 27 | className?: string 28 | }) { 29 | return ( 30 | <div className={`mx-auto w-full transition-all ${className}`}> 31 | <div className="bg-background p-2"> 32 | <div className="mt-2 flex flex-col items-start space-y-2 mb-4"> 33 | {exampleMessages.map((message, index) => ( 34 | <Button 35 | key={index} 36 | variant="link" 37 | className="h-auto p-0 text-base" 38 | name={message.message} 39 | onClick={async () => { 40 | submitMessage(message.message) 41 | }} 42 | > 43 | <ArrowRight size={16} className="mr-2 text-muted-foreground" /> 44 | {message.heading} 45 | </Button> 46 | ))} 47 | </div> 48 | </div> 49 | </div> 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /components/external-link-items.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DropdownMenuItem } from '@/components/ui/dropdown-menu' 4 | import Link from 'next/link' 5 | import { SiDiscord, SiGithub, SiX } from 'react-icons/si' 6 | 7 | const externalLinks = [ 8 | { 9 | name: 'X', 10 | href: 'https://x.com/morphic_ai', 11 | icon: <SiX className="mr-2 h-4 w-4" /> 12 | }, 13 | { 14 | name: 'Discord', 15 | href: 'https://discord.gg/zRxaseCuGq', 16 | icon: <SiDiscord className="mr-2 h-4 w-4" /> 17 | }, 18 | { 19 | name: 'GitHub', 20 | href: 'https://git.new/morphic', 21 | icon: <SiGithub className="mr-2 h-4 w-4" /> 22 | } 23 | ] 24 | 25 | export function ExternalLinkItems() { 26 | return ( 27 | <> 28 | {externalLinks.map(link => ( 29 | <DropdownMenuItem key={link.name} asChild> 30 | <Link href={link.href} target="_blank" rel="noopener noreferrer"> 31 | {link.icon} 32 | <span>{link.name}</span> 33 | </Link> 34 | </DropdownMenuItem> 35 | ))} 36 | </> 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/guest-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuSeparator, 9 | DropdownMenuSub, 10 | DropdownMenuSubContent, 11 | DropdownMenuSubTrigger, 12 | DropdownMenuTrigger 13 | } from '@/components/ui/dropdown-menu' 14 | import { 15 | Link2, 16 | LogIn, 17 | Palette, 18 | Settings2 // Or EllipsisVertical, etc. 19 | } from 'lucide-react' 20 | import Link from 'next/link' 21 | import { ExternalLinkItems } from './external-link-items' 22 | import { ThemeMenuItems } from './theme-menu-items' 23 | 24 | export default function GuestMenu() { 25 | return ( 26 | <DropdownMenu> 27 | <DropdownMenuTrigger asChild> 28 | <Button variant="ghost" size="icon"> 29 | <Settings2 className="h-5 w-5" /> {/* Choose an icon */} 30 | <span className="sr-only">Open menu</span> 31 | </Button> 32 | </DropdownMenuTrigger> 33 | <DropdownMenuContent className="w-56" align="end" forceMount> 34 | <DropdownMenuItem asChild> 35 | <Link href="/auth/login"> 36 | <LogIn className="mr-2 h-4 w-4" /> 37 | <span>Sign In</span> 38 | </Link> 39 | </DropdownMenuItem> 40 | <DropdownMenuSeparator /> 41 | <DropdownMenuSub> 42 | <DropdownMenuSubTrigger> 43 | <Palette className="mr-2 h-4 w-4" /> 44 | <span>Theme</span> 45 | </DropdownMenuSubTrigger> 46 | <DropdownMenuSubContent> 47 | <ThemeMenuItems /> 48 | </DropdownMenuSubContent> 49 | </DropdownMenuSub> 50 | <DropdownMenuSub> 51 | <DropdownMenuSubTrigger> 52 | <Link2 className="mr-2 h-4 w-4" /> 53 | <span>Links</span> 54 | </DropdownMenuSubTrigger> 55 | <DropdownMenuSubContent> 56 | <ExternalLinkItems /> 57 | </DropdownMenuSubContent> 58 | </DropdownMenuSub> 59 | </DropdownMenuContent> 60 | </DropdownMenu> 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useSidebar } from '@/components/ui/sidebar' 4 | import { cn } from '@/lib/utils' 5 | import { User } from '@supabase/supabase-js' 6 | // import Link from 'next/link' // No longer needed directly here for Sign In button 7 | import React from 'react' 8 | // import { Button } from './ui/button' // No longer needed directly here for Sign In button 9 | import GuestMenu from './guest-menu' // Import the new GuestMenu component 10 | import UserMenu from './user-menu' 11 | 12 | interface HeaderProps { 13 | user: User | null 14 | } 15 | 16 | export const Header: React.FC<HeaderProps> = ({ user }) => { 17 | const { open } = useSidebar() 18 | return ( 19 | <header 20 | className={cn( 21 | 'absolute top-0 right-0 p-2 flex justify-between items-center z-10 backdrop-blur lg:backdrop-blur-none bg-background/80 lg:bg-transparent transition-[width] duration-200 ease-linear', 22 | open ? 'md:w-[calc(100%-var(--sidebar-width))]' : 'md:w-full', 23 | 'w-full' 24 | )} 25 | > 26 | {/* This div can be used for a logo or title on the left if needed */} 27 | <div></div> 28 | 29 | <div className="flex items-center gap-2"> 30 | {user ? <UserMenu user={user} /> : <GuestMenu />} 31 | </div> 32 | </header> 33 | ) 34 | } 35 | 36 | export default Header 37 | -------------------------------------------------------------------------------- /components/inspector/inspector-drawer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useArtifact } from '@/components/artifact/artifact-context' 4 | import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer' 5 | import { useMediaQuery } from '@/lib/hooks/use-media-query' 6 | import { VisuallyHidden } from '@radix-ui/react-visually-hidden' 7 | import { InspectorPanel } from './inspector-panel' 8 | 9 | export function InspectorDrawer() { 10 | const { state, close } = useArtifact() 11 | const part = state.part 12 | const isMobile = useMediaQuery('(max-width: 767px)') 13 | 14 | // Function to get the title based on part type (mirrors ArtifactPanel logic) 15 | const getTitle = () => { 16 | if (!part) return 'Artifact' // Default title 17 | switch (part.type) { 18 | case 'tool-invocation': 19 | return part.toolInvocation.toolName 20 | case 'reasoning': 21 | return 'Reasoning' 22 | case 'text': 23 | return 'Text' 24 | default: 25 | return 'Content' 26 | } 27 | } 28 | 29 | if (!isMobile) return null 30 | 31 | return ( 32 | <Drawer 33 | open={state.isOpen} 34 | onOpenChange={open => { 35 | if (!open) close() 36 | }} 37 | modal={true} 38 | > 39 | <DrawerContent className="p-0 max-h-[90vh] md:hidden"> 40 | <DrawerTitle asChild> 41 | <VisuallyHidden>{getTitle()}</VisuallyHidden> 42 | </DrawerTitle> 43 | <InspectorPanel /> 44 | </DrawerContent> 45 | </Drawer> 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /components/inspector/inspector-panel.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ArtifactContent } from '@/components/artifact/artifact-content' 4 | import { useArtifact } from '@/components/artifact/artifact-context' 5 | import { Button } from '@/components/ui/button' 6 | import { Separator } from '@/components/ui/separator' 7 | import { TooltipButton } from '@/components/ui/tooltip-button' 8 | import { 9 | Tooltip, 10 | TooltipContent, 11 | TooltipProvider, 12 | TooltipTrigger 13 | } from '@/components/ui/tooltip' 14 | import { LightbulbIcon, MessageSquare, Minimize2, Wrench } from 'lucide-react' 15 | 16 | export function InspectorPanel() { 17 | const { state, close } = useArtifact() 18 | const part = state.part 19 | if (!part) return null 20 | 21 | // Get the icon and title based on part type 22 | const getIconAndTitle = () => { 23 | switch (part.type) { 24 | case 'tool-invocation': 25 | return { 26 | icon: <Wrench size={18} />, 27 | title: part.toolInvocation.toolName 28 | } 29 | case 'reasoning': 30 | return { 31 | icon: <LightbulbIcon size={18} />, 32 | title: 'Reasoning' 33 | } 34 | case 'text': 35 | return { 36 | icon: <MessageSquare size={18} />, 37 | title: 'Text' 38 | } 39 | default: 40 | return { 41 | icon: <MessageSquare size={18} />, 42 | title: 'Content' 43 | } 44 | } 45 | } 46 | 47 | const { icon, title } = getIconAndTitle() 48 | 49 | return ( 50 | <TooltipProvider> 51 | <div className="h-full flex flex-col overflow-hidden bg-muted md:px-4 md:pt-14 md:pb-4"> 52 | <div className="flex flex-col h-full bg-background rounded-xl md:border overflow-hidden"> 53 | <div className="flex items-center justify-between px-4 py-2"> 54 | <h3 className="flex items-center gap-2"> 55 | <div className="bg-muted p-2 rounded-md flex items-center gap-2"> 56 | {icon} 57 | </div> 58 | <span className="text-sm font-medium capitalize">{title}</span> 59 | </h3> 60 | <TooltipButton 61 | variant="ghost" 62 | size="icon" 63 | onClick={close} 64 | aria-label="Close panel" 65 | tooltipContent="Minimize" 66 | > 67 | <Minimize2 className="h-4 w-4" /> 68 | </TooltipButton> 69 | </div> 70 | <Separator className="my-1 bg-border/50" /> 71 | <div data-vaul-no-drag className="flex-1 overflow-y-auto p-4"> 72 | <ArtifactContent part={part} /> 73 | </div> 74 | </div> 75 | </div> 76 | </TooltipProvider> 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /components/message-actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CHAT_ID } from '@/lib/constants' 4 | import { cn } from '@/lib/utils' 5 | import { useChat } from '@ai-sdk/react' 6 | import { Copy } from 'lucide-react' 7 | import { toast } from 'sonner' 8 | import { ChatShare } from './chat-share' 9 | import { RetryButton } from './retry-button' 10 | import { Button } from './ui/button' 11 | 12 | interface MessageActionsProps { 13 | message: string 14 | messageId: string 15 | reload?: () => Promise<string | null | undefined> 16 | chatId?: string 17 | enableShare?: boolean 18 | className?: string 19 | } 20 | 21 | export function MessageActions({ 22 | message, 23 | messageId, 24 | reload, 25 | chatId, 26 | enableShare, 27 | className 28 | }: MessageActionsProps) { 29 | const { status } = useChat({ 30 | id: CHAT_ID 31 | }) 32 | const isLoading = status === 'submitted' || status === 'streaming' 33 | 34 | async function handleCopy() { 35 | await navigator.clipboard.writeText(message) 36 | toast.success('Message copied to clipboard') 37 | } 38 | 39 | return ( 40 | <div 41 | className={cn( 42 | 'flex items-center gap-0.5 self-end transition-opacity duration-200', 43 | isLoading ? 'opacity-0' : 'opacity-100', 44 | className 45 | )} 46 | > 47 | {reload && <RetryButton reload={reload} messageId={messageId} />} 48 | <Button 49 | variant="ghost" 50 | size="icon" 51 | onClick={handleCopy} 52 | className="rounded-full" 53 | > 54 | <Copy size={14} /> 55 | </Button> 56 | {enableShare && chatId && <ChatShare chatId={chatId} />} 57 | </div> 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /components/message.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import 'katex/dist/katex.min.css' 5 | import rehypeExternalLinks from 'rehype-external-links' 6 | import rehypeKatex from 'rehype-katex' 7 | import remarkGfm from 'remark-gfm' 8 | import remarkMath from 'remark-math' 9 | import { Citing } from './custom-link' 10 | import { CodeBlock } from './ui/codeblock' 11 | import { MemoizedReactMarkdown } from './ui/markdown' 12 | 13 | export function BotMessage({ 14 | message, 15 | className 16 | }: { 17 | message: string 18 | className?: string 19 | }) { 20 | // Check if the content contains LaTeX patterns 21 | const containsLaTeX = /\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)/.test( 22 | message || '' 23 | ) 24 | 25 | // Modify the content to render LaTeX equations if LaTeX patterns are found 26 | const processedData = preprocessLaTeX(message || '') 27 | 28 | if (containsLaTeX) { 29 | return ( 30 | <MemoizedReactMarkdown 31 | rehypePlugins={[ 32 | [rehypeExternalLinks, { target: '_blank' }], 33 | [rehypeKatex] 34 | ]} 35 | remarkPlugins={[remarkGfm, remarkMath]} 36 | className={cn( 37 | 'prose-sm prose-neutral prose-a:text-accent-foreground/50', 38 | className 39 | )} 40 | > 41 | {processedData} 42 | </MemoizedReactMarkdown> 43 | ) 44 | } 45 | 46 | return ( 47 | <MemoizedReactMarkdown 48 | rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]} 49 | remarkPlugins={[remarkGfm]} 50 | className={cn( 51 | 'prose-sm prose-neutral prose-a:text-accent-foreground/50', 52 | className 53 | )} 54 | components={{ 55 | code({ node, inline, className, children, ...props }) { 56 | if (children.length) { 57 | if (children[0] == '▍') { 58 | return ( 59 | <span className="mt-1 cursor-default animate-pulse">▍</span> 60 | ) 61 | } 62 | 63 | children[0] = (children[0] as string).replace('`▍`', '▍') 64 | } 65 | 66 | const match = /language-(\w+)/.exec(className || '') 67 | 68 | if (inline) { 69 | return ( 70 | <code className={className} {...props}> 71 | {children} 72 | </code> 73 | ) 74 | } 75 | 76 | return ( 77 | <CodeBlock 78 | key={Math.random()} 79 | language={(match && match[1]) || ''} 80 | value={String(children).replace(/\n$/, '')} 81 | {...props} 82 | /> 83 | ) 84 | }, 85 | a: Citing 86 | }} 87 | > 88 | {message} 89 | </MemoizedReactMarkdown> 90 | ) 91 | } 92 | 93 | // Preprocess LaTeX equations to be rendered by KaTeX 94 | // ref: https://github.com/remarkjs/react-markdown/issues/785 95 | const preprocessLaTeX = (content: string) => { 96 | const blockProcessedContent = content.replace( 97 | /\\\[([\s\S]*?)\\\]/g, 98 | (_, equation) => `$$${equation}$$` 99 | ) 100 | const inlineProcessedContent = blockProcessedContent.replace( 101 | /\\\(([\s\S]*?)\\\)/g, 102 | (_, equation) => `$${equation}$` 103 | ) 104 | return inlineProcessedContent 105 | } 106 | -------------------------------------------------------------------------------- /components/reasoning-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Badge } from '@/components/ui/badge' 4 | import { Check, Lightbulb, Loader2 } from 'lucide-react' 5 | import { CollapsibleMessage } from './collapsible-message' 6 | import { DefaultSkeleton } from './default-skeleton' 7 | import { BotMessage } from './message' 8 | import { StatusIndicator } from './ui/status-indicator' 9 | 10 | interface ReasoningContent { 11 | reasoning: string 12 | time?: number 13 | } 14 | 15 | export interface ReasoningSectionProps { 16 | content: ReasoningContent 17 | isOpen: boolean 18 | onOpenChange: (open: boolean) => void 19 | } 20 | 21 | export function ReasoningSection({ 22 | content, 23 | isOpen, 24 | onOpenChange 25 | }: ReasoningSectionProps) { 26 | const reasoningHeader = ( 27 | <div className="flex items-center gap-2 w-full"> 28 | <div className="w-full flex flex-col"> 29 | <div className="flex items-center justify-between"> 30 | <Badge className="flex items-center gap-0.5" variant="secondary"> 31 | <Lightbulb size={16} /> 32 | {content.time === 0 33 | ? 'Thinking...' 34 | : content.time !== undefined && content.time > 0 35 | ? `Thought for ${(content.time / 1000).toFixed(1)} seconds` 36 | : 'Thoughts'} 37 | </Badge> 38 | {content.time === 0 ? ( 39 | <Loader2 40 | size={16} 41 | className="animate-spin text-muted-foreground/50" 42 | /> 43 | ) : ( 44 | <StatusIndicator icon={Check} iconClassName="text-green-500"> 45 | {`${content.reasoning.length.toLocaleString()} characters`} 46 | </StatusIndicator> 47 | )} 48 | </div> 49 | </div> 50 | </div> 51 | ) 52 | 53 | if (!content) return <DefaultSkeleton /> 54 | 55 | return ( 56 | <div className="flex flex-col gap-4"> 57 | <CollapsibleMessage 58 | role="assistant" 59 | isCollapsible={true} 60 | header={reasoningHeader} 61 | isOpen={isOpen} 62 | onOpenChange={onOpenChange} 63 | showBorder={true} 64 | showIcon={false} 65 | > 66 | <BotMessage 67 | message={content.reasoning} 68 | className="prose-p:text-muted-foreground" 69 | /> 70 | </CollapsibleMessage> 71 | </div> 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /components/related-questions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CHAT_ID } from '@/lib/constants' 4 | import { useChat } from '@ai-sdk/react' 5 | import { JSONValue } from 'ai' 6 | import { ArrowRight } from 'lucide-react' 7 | import React from 'react' 8 | import { CollapsibleMessage } from './collapsible-message' 9 | import { Section } from './section' 10 | import { Button } from './ui/button' 11 | import { Skeleton } from './ui/skeleton' 12 | 13 | export interface RelatedQuestionsProps { 14 | annotations: JSONValue[] 15 | onQuerySelect: (query: string) => void 16 | isOpen: boolean 17 | onOpenChange: (open: boolean) => void 18 | } 19 | 20 | interface RelatedQuestionsAnnotation extends Record<string, JSONValue> { 21 | type: 'related-questions' 22 | data: { 23 | items: Array<{ query: string }> 24 | } 25 | } 26 | 27 | export const RelatedQuestions: React.FC<RelatedQuestionsProps> = ({ 28 | annotations, 29 | onQuerySelect, 30 | isOpen, 31 | onOpenChange 32 | }) => { 33 | const { status } = useChat({ 34 | id: CHAT_ID 35 | }) 36 | const isLoading = status === 'submitted' || status === 'streaming' 37 | 38 | if (!annotations) { 39 | return null 40 | } 41 | 42 | const lastRelatedQuestionsAnnotation = annotations[ 43 | annotations.length - 1 44 | ] as RelatedQuestionsAnnotation 45 | 46 | const relatedQuestions = lastRelatedQuestionsAnnotation?.data 47 | if ((!relatedQuestions || !relatedQuestions.items) && !isLoading) { 48 | return null 49 | } 50 | 51 | if (relatedQuestions.items.length === 0 && isLoading) { 52 | return ( 53 | <CollapsibleMessage 54 | role="assistant" 55 | isCollapsible={false} 56 | isOpen={isOpen} 57 | onOpenChange={onOpenChange} 58 | showIcon={false} 59 | > 60 | <Skeleton className="w-full h-6" /> 61 | </CollapsibleMessage> 62 | ) 63 | } 64 | 65 | return ( 66 | <CollapsibleMessage 67 | role="assistant" 68 | isCollapsible={false} 69 | isOpen={isOpen} 70 | onOpenChange={onOpenChange} 71 | showIcon={false} 72 | showBorder={false} 73 | > 74 | <Section title="Related" className="pt-0 pb-4"> 75 | <div className="flex flex-col"> 76 | {Array.isArray(relatedQuestions.items) ? ( 77 | relatedQuestions.items 78 | ?.filter(item => item?.query !== '') 79 | .map((item, index) => ( 80 | <div className="flex items-start w-full" key={index}> 81 | <ArrowRight className="h-4 w-4 mr-2 mt-1 flex-shrink-0 text-accent-foreground/50" /> 82 | <Button 83 | variant="link" 84 | className="flex-1 justify-start px-0 py-1 h-fit font-semibold text-accent-foreground/50 whitespace-normal text-left" 85 | type="submit" 86 | name={'related_query'} 87 | value={item?.query} 88 | onClick={() => onQuerySelect(item?.query)} 89 | > 90 | {item?.query} 91 | </Button> 92 | </div> 93 | )) 94 | ) : ( 95 | <div>Not an array</div> 96 | )} 97 | </div> 98 | </Section> 99 | </CollapsibleMessage> 100 | ) 101 | } 102 | export default RelatedQuestions 103 | -------------------------------------------------------------------------------- /components/retrieve-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useArtifact } from '@/components/artifact/artifact-context' 4 | import { SearchResults } from '@/components/search-results' 5 | import { Section, ToolArgsSection } from '@/components/section' 6 | import { SearchResults as SearchResultsType } from '@/lib/types' 7 | import { ToolInvocation } from 'ai' 8 | import { CollapsibleMessage } from './collapsible-message' 9 | import { DefaultSkeleton } from './default-skeleton' 10 | 11 | interface RetrieveSectionProps { 12 | tool: ToolInvocation 13 | isOpen: boolean 14 | onOpenChange: (open: boolean) => void 15 | } 16 | 17 | export function RetrieveSection({ 18 | tool, 19 | isOpen, 20 | onOpenChange 21 | }: RetrieveSectionProps) { 22 | const isLoading = tool.state === 'call' 23 | const data: SearchResultsType = 24 | tool.state === 'result' ? tool.result : undefined 25 | const url = tool.args.url as string | undefined 26 | 27 | const { open } = useArtifact() 28 | const header = ( 29 | <button 30 | type="button" 31 | onClick={() => open({ type: 'tool-invocation', toolInvocation: tool })} 32 | className="flex items-center justify-between w-full text-left rounded-md p-1 -ml-1" 33 | title="Open details" 34 | > 35 | <ToolArgsSection tool="retrieve" number={data?.results?.length}> 36 | {url} 37 | </ToolArgsSection> 38 | </button> 39 | ) 40 | 41 | return ( 42 | <CollapsibleMessage 43 | role="assistant" 44 | isCollapsible={true} 45 | header={header} 46 | isOpen={isOpen} 47 | onOpenChange={onOpenChange} 48 | showIcon={false} 49 | > 50 | {!isLoading && data ? ( 51 | <Section title="Sources"> 52 | <SearchResults results={data.results} /> 53 | </Section> 54 | ) : ( 55 | <DefaultSkeleton /> 56 | )} 57 | </CollapsibleMessage> 58 | ) 59 | } 60 | 61 | export default RetrieveSection 62 | -------------------------------------------------------------------------------- /components/retry-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { RotateCcw } from 'lucide-react' 4 | import { Button } from './ui/button' 5 | 6 | interface RetryButtonProps { 7 | reload: () => Promise<string | null | undefined> 8 | messageId: string 9 | } 10 | 11 | export const RetryButton: React.FC<RetryButtonProps> = ({ 12 | reload, 13 | messageId 14 | }) => { 15 | return ( 16 | <Button 17 | className="rounded-full h-8 w-8" 18 | type="button" 19 | variant="ghost" 20 | size="icon" 21 | onClick={() => reload()} 22 | aria-label={`Retry from message ${messageId}`} 23 | > 24 | <RotateCcw className="w-4 h-4" /> 25 | <span className="sr-only">Retry</span> 26 | </Button> 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/search-mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { getCookie, setCookie } from '@/lib/utils/cookies' 5 | import { Globe } from 'lucide-react' 6 | import { useEffect, useState } from 'react' 7 | import { Toggle } from './ui/toggle' 8 | 9 | export function SearchModeToggle() { 10 | const [isSearchMode, setIsSearchMode] = useState(true) 11 | 12 | useEffect(() => { 13 | const savedMode = getCookie('search-mode') 14 | if (savedMode !== null) { 15 | setIsSearchMode(savedMode === 'true') 16 | } else { 17 | setCookie('search-mode', 'true') 18 | } 19 | }, []) 20 | 21 | const handleSearchModeChange = (pressed: boolean) => { 22 | setIsSearchMode(pressed) 23 | setCookie('search-mode', pressed.toString()) 24 | } 25 | 26 | return ( 27 | <Toggle 28 | aria-label="Toggle search mode" 29 | pressed={isSearchMode} 30 | onPressedChange={handleSearchModeChange} 31 | variant="outline" 32 | className={cn( 33 | 'gap-1 px-3 border border-input text-muted-foreground bg-background', 34 | 'data-[state=on]:bg-accent-blue', 35 | 'data-[state=on]:text-accent-blue-foreground', 36 | 'data-[state=on]:border-accent-blue-border', 37 | 'hover:bg-accent hover:text-accent-foreground rounded-full' 38 | )} 39 | > 40 | <Globe className="size-4" /> 41 | <span className="text-xs">Search</span> 42 | </Toggle> 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/search-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useArtifact } from '@/components/artifact/artifact-context' 4 | import { CHAT_ID } from '@/lib/constants' 5 | import type { SearchResults as TypeSearchResults } from '@/lib/types' 6 | import { useChat } from '@ai-sdk/react' 7 | import { ToolInvocation } from 'ai' 8 | import { CollapsibleMessage } from './collapsible-message' 9 | import { SearchSkeleton } from './default-skeleton' 10 | import { SearchResults } from './search-results' 11 | import { SearchResultsImageSection } from './search-results-image' 12 | import { Section, ToolArgsSection } from './section' 13 | 14 | interface SearchSectionProps { 15 | tool: ToolInvocation 16 | isOpen: boolean 17 | onOpenChange: (open: boolean) => void 18 | } 19 | 20 | export function SearchSection({ 21 | tool, 22 | isOpen, 23 | onOpenChange 24 | }: SearchSectionProps) { 25 | const { status } = useChat({ 26 | id: CHAT_ID 27 | }) 28 | const isLoading = status === 'submitted' || status === 'streaming' 29 | 30 | const isToolLoading = tool.state === 'call' 31 | const searchResults: TypeSearchResults = 32 | tool.state === 'result' ? tool.result : undefined 33 | const query = tool.args?.query as string | undefined 34 | const includeDomains = tool.args?.includeDomains as string[] | undefined 35 | const includeDomainsString = includeDomains 36 | ? ` [${includeDomains.join(', ')}]` 37 | : '' 38 | 39 | const { open } = useArtifact() 40 | const header = ( 41 | <button 42 | type="button" 43 | onClick={() => open({ type: 'tool-invocation', toolInvocation: tool })} 44 | className="flex items-center justify-between w-full text-left rounded-md p-1 -ml-1" 45 | title="Open details" 46 | > 47 | <ToolArgsSection 48 | tool="search" 49 | number={searchResults?.results?.length} 50 | >{`${query}${includeDomainsString}`}</ToolArgsSection> 51 | </button> 52 | ) 53 | 54 | return ( 55 | <CollapsibleMessage 56 | role="assistant" 57 | isCollapsible={true} 58 | header={header} 59 | isOpen={isOpen} 60 | onOpenChange={onOpenChange} 61 | showIcon={false} 62 | > 63 | {searchResults && 64 | searchResults.images && 65 | searchResults.images.length > 0 && ( 66 | <Section> 67 | <SearchResultsImageSection 68 | images={searchResults.images} 69 | query={query} 70 | /> 71 | </Section> 72 | )} 73 | {isLoading && isToolLoading ? ( 74 | <SearchSkeleton /> 75 | ) : searchResults?.results ? ( 76 | <Section title="Sources"> 77 | <SearchResults results={searchResults.results} /> 78 | </Section> 79 | ) : null} 80 | </CollapsibleMessage> 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /components/section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { 5 | BookCheck, 6 | Check, 7 | File, 8 | Film, 9 | Image, 10 | MessageCircleMore, 11 | Newspaper, 12 | Repeat2, 13 | Search 14 | } from 'lucide-react' 15 | import React from 'react' 16 | import { ToolBadge } from './tool-badge' 17 | import { Badge } from './ui/badge' 18 | import { Separator } from './ui/separator' 19 | import { StatusIndicator } from './ui/status-indicator' 20 | 21 | type SectionProps = { 22 | children: React.ReactNode 23 | className?: string 24 | size?: 'sm' | 'md' | 'lg' 25 | title?: string 26 | separator?: boolean 27 | } 28 | 29 | export const Section: React.FC<SectionProps> = ({ 30 | children, 31 | className, 32 | size = 'md', 33 | title, 34 | separator = false 35 | }) => { 36 | const iconSize = 16 37 | const iconClassName = 'mr-1.5 text-muted-foreground' 38 | let icon: React.ReactNode 39 | let type: 'text' | 'badge' = 'text' 40 | switch (title) { 41 | case 'Images': 42 | // eslint-disable-next-line jsx-a11y/alt-text 43 | icon = <Image size={iconSize} className={iconClassName} /> 44 | break 45 | case 'Videos': 46 | icon = <Film size={iconSize} className={iconClassName} /> 47 | type = 'badge' 48 | break 49 | case 'Sources': 50 | icon = <Newspaper size={iconSize} className={iconClassName} /> 51 | type = 'badge' 52 | break 53 | case 'Answer': 54 | icon = <BookCheck size={iconSize} className={iconClassName} /> 55 | break 56 | case 'Related': 57 | icon = <Repeat2 size={iconSize} className={iconClassName} /> 58 | break 59 | case 'Follow-up': 60 | icon = <MessageCircleMore size={iconSize} className={iconClassName} /> 61 | break 62 | case 'Content': 63 | icon = <File size={iconSize} className={iconClassName} /> 64 | type = 'badge' 65 | break 66 | default: 67 | icon = <Search size={iconSize} className={iconClassName} /> 68 | } 69 | 70 | return ( 71 | <> 72 | {separator && <Separator className="my-2 bg-primary/10" />} 73 | <section 74 | className={cn( 75 | ` ${size === 'sm' ? 'py-1' : size === 'lg' ? 'py-4' : 'py-2'}`, 76 | className 77 | )} 78 | > 79 | {title && type === 'text' && ( 80 | <h2 className="flex items-center leading-none py-2"> 81 | {icon} 82 | {title} 83 | </h2> 84 | )} 85 | {title && type === 'badge' && ( 86 | <Badge variant="secondary" className="mb-2"> 87 | {icon} 88 | {title} 89 | </Badge> 90 | )} 91 | {children} 92 | </section> 93 | </> 94 | ) 95 | } 96 | 97 | export function ToolArgsSection({ 98 | children, 99 | tool, 100 | number 101 | }: { 102 | children: React.ReactNode 103 | tool: string 104 | number?: number 105 | }) { 106 | return ( 107 | <Section 108 | size="sm" 109 | className="py-0 flex items-center justify-between w-full" 110 | > 111 | <ToolBadge tool={tool}>{children}</ToolBadge> 112 | {number && ( 113 | <StatusIndicator icon={Check} iconClassName="text-green-500"> 114 | {number} results 115 | </StatusIndicator> 116 | )} 117 | </Section> 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /components/sidebar/chat-history-section.tsx: -------------------------------------------------------------------------------- 1 | import { ChatHistoryClient } from './chat-history-client' 2 | 3 | export async function ChatHistorySection() { 4 | const enableSaveChatHistory = process.env.ENABLE_SAVE_CHAT_HISTORY === 'true' 5 | if (!enableSaveChatHistory) { 6 | return null 7 | } 8 | 9 | return <ChatHistoryClient /> 10 | } 11 | -------------------------------------------------------------------------------- /components/sidebar/chat-history-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SidebarMenu, 3 | SidebarMenuItem, 4 | SidebarMenuSkeleton 5 | } from '@/components/ui/sidebar' 6 | 7 | export function ChatHistorySkeleton() { 8 | return ( 9 | <SidebarMenu> 10 | {Array.from({ length: 5 }).map((_, idx) => ( 11 | <SidebarMenuItem key={idx}> 12 | <SidebarMenuSkeleton showIcon={false} /> 13 | </SidebarMenuItem> 14 | ))} 15 | </SidebarMenu> 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/sidebar/clear-history-action.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger 13 | } from '@/components/ui/alert-dialog' 14 | import { 15 | DropdownMenu, 16 | DropdownMenuContent, 17 | DropdownMenuItem, 18 | DropdownMenuTrigger 19 | } from '@/components/ui/dropdown-menu' 20 | import { SidebarGroupAction } from '@/components/ui/sidebar' 21 | import { Spinner } from '@/components/ui/spinner' 22 | import { clearChats } from '@/lib/actions/chat' 23 | import { MoreHorizontal, Trash2 } from 'lucide-react' 24 | import { useState, useTransition } from 'react' 25 | import { toast } from 'sonner' 26 | 27 | interface ClearHistoryActionProps { 28 | empty: boolean 29 | } 30 | 31 | export function ClearHistoryAction({ empty }: ClearHistoryActionProps) { 32 | const [isPending, start] = useTransition() 33 | const [open, setOpen] = useState(false) 34 | 35 | const onClear = () => 36 | start(async () => { 37 | const res = await clearChats() 38 | res?.error ? toast.error(res.error) : toast.success('History cleared') 39 | setOpen(false) 40 | window.dispatchEvent(new CustomEvent('chat-history-updated')) 41 | }) 42 | 43 | return ( 44 | <DropdownMenu> 45 | <DropdownMenuTrigger asChild> 46 | <SidebarGroupAction disabled={empty} className="static size-7 p-1"> 47 | <MoreHorizontal size={16} /> 48 | <span className="sr-only">History Actions</span> 49 | </SidebarGroupAction> 50 | </DropdownMenuTrigger> 51 | 52 | <DropdownMenuContent align="end"> 53 | <AlertDialog open={open} onOpenChange={setOpen}> 54 | <AlertDialogTrigger asChild> 55 | <DropdownMenuItem 56 | disabled={empty || isPending} 57 | className="gap-2 text-destructive focus:text-destructive" 58 | onSelect={event => event.preventDefault()} // Prevent closing dropdown 59 | > 60 | <Trash2 size={14} /> Clear History 61 | </DropdownMenuItem> 62 | </AlertDialogTrigger> 63 | 64 | <AlertDialogContent> 65 | <AlertDialogHeader> 66 | <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 67 | <AlertDialogDescription> 68 | This action cannot be undone. It will permanently delete your 69 | history. 70 | </AlertDialogDescription> 71 | </AlertDialogHeader> 72 | <AlertDialogFooter> 73 | <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> 74 | <AlertDialogAction disabled={isPending} onClick={onClear}> 75 | {isPending ? <Spinner /> : 'Clear'} 76 | </AlertDialogAction> 77 | </AlertDialogFooter> 78 | </AlertDialogContent> 79 | </AlertDialog> 80 | </DropdownMenuContent> 81 | </DropdownMenu> 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /components/theme-menu-items.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DropdownMenuItem } from '@/components/ui/dropdown-menu' 4 | import { Laptop, Moon, Sun } from 'lucide-react' 5 | import { useTheme } from 'next-themes' 6 | 7 | export function ThemeMenuItems() { 8 | const { setTheme } = useTheme() 9 | 10 | return ( 11 | <> 12 | <DropdownMenuItem onClick={() => setTheme('light')}> 13 | <Sun className="mr-2 h-4 w-4" /> 14 | <span>Light</span> 15 | </DropdownMenuItem> 16 | <DropdownMenuItem onClick={() => setTheme('dark')}> 17 | <Moon className="mr-2 h-4 w-4" /> 18 | <span>Dark</span> 19 | </DropdownMenuItem> 20 | <DropdownMenuItem onClick={() => setTheme('system')}> 21 | <Laptop className="mr-2 h-4 w-4" /> 22 | <span>System</span> 23 | </DropdownMenuItem> 24 | </> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 5 | import { type ThemeProviderProps } from 'next-themes/dist/types' 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return <NextThemesProvider {...props}>{children}</NextThemesProvider> 9 | } 10 | -------------------------------------------------------------------------------- /components/tool-badge.tsx: -------------------------------------------------------------------------------- 1 | import { Film, Link, Search } from 'lucide-react' 2 | import React from 'react' 3 | import { Badge } from './ui/badge' 4 | 5 | type ToolBadgeProps = { 6 | tool: string 7 | children: React.ReactNode 8 | className?: string 9 | } 10 | 11 | export const ToolBadge: React.FC<ToolBadgeProps> = ({ 12 | tool, 13 | children, 14 | className 15 | }) => { 16 | const icon: Record<string, React.ReactNode> = { 17 | search: <Search size={14} />, 18 | retrieve: <Link size={14} />, 19 | videoSearch: <Film size={14} /> 20 | } 21 | 22 | return ( 23 | <Badge className={className} variant={'secondary'}> 24 | {icon[tool]} 25 | <span className="ml-1">{children}</span> 26 | </Badge> 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/tool-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ToolInvocation } from 'ai' 4 | import { QuestionConfirmation } from './question-confirmation' 5 | import RetrieveSection from './retrieve-section' 6 | import { SearchSection } from './search-section' 7 | import { VideoSearchSection } from './video-search-section' 8 | 9 | interface ToolSectionProps { 10 | tool: ToolInvocation 11 | isOpen: boolean 12 | onOpenChange: (open: boolean) => void 13 | addToolResult?: (params: { toolCallId: string; result: any }) => void 14 | } 15 | 16 | export function ToolSection({ 17 | tool, 18 | isOpen, 19 | onOpenChange, 20 | addToolResult 21 | }: ToolSectionProps) { 22 | // Special handling for ask_question tool 23 | if (tool.toolName === 'ask_question') { 24 | // When waiting for user input 25 | if (tool.state === 'call' && addToolResult) { 26 | return ( 27 | <QuestionConfirmation 28 | toolInvocation={tool} 29 | onConfirm={(toolCallId, approved, response) => { 30 | addToolResult({ 31 | toolCallId, 32 | result: approved 33 | ? response 34 | : { 35 | declined: true, 36 | skipped: response?.skipped, 37 | message: 'User declined this question' 38 | } 39 | }) 40 | }} 41 | /> 42 | ) 43 | } 44 | 45 | // When result is available, display the result 46 | if (tool.state === 'result') { 47 | return ( 48 | <QuestionConfirmation 49 | toolInvocation={tool} 50 | isCompleted={true} 51 | onConfirm={() => {}} // Not used in result display mode 52 | /> 53 | ) 54 | } 55 | } 56 | 57 | switch (tool.toolName) { 58 | case 'search': 59 | return ( 60 | <SearchSection 61 | tool={tool} 62 | isOpen={isOpen} 63 | onOpenChange={onOpenChange} 64 | /> 65 | ) 66 | case 'videoSearch': 67 | return ( 68 | <VideoSearchSection 69 | tool={tool} 70 | isOpen={isOpen} 71 | onOpenChange={onOpenChange} 72 | /> 73 | ) 74 | case 'retrieve': 75 | return ( 76 | <RetrieveSection 77 | tool={tool} 78 | isOpen={isOpen} 79 | onOpenChange={onOpenChange} 80 | /> 81 | ) 82 | default: 83 | return null 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 4 | import * as React from 'react' 5 | 6 | import { cn } from '@/lib/utils/index' 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef<typeof AvatarPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <AvatarPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | 'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', 16 | className 17 | )} 18 | {...props} 19 | /> 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef<typeof AvatarPrimitive.Image>, 25 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> 26 | >(({ className, ...props }, ref) => ( 27 | <AvatarPrimitive.Image 28 | ref={ref} 29 | className={cn('aspect-square h-full w-full', className)} 30 | {...props} 31 | /> 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef<typeof AvatarPrimitive.Fallback>, 37 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> 38 | >(({ className, ...props }, ref) => ( 39 | <AvatarPrimitive.Fallback 40 | ref={ref} 41 | className={cn( 42 | 'flex h-full w-full items-center justify-center rounded-full bg-muted', 43 | className 44 | )} 45 | {...props} 46 | /> 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarFallback, AvatarImage } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 13 | secondary: 14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 15 | destructive: 16 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 17 | outline: 'text-foreground' 18 | } 19 | }, 20 | defaultVariants: { 21 | variant: 'default' 22 | } 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes<HTMLDivElement>, 28 | VariantProps<typeof badgeVariants> {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 | <div className={cn(badgeVariants({ variant }), className)} {...props} /> 33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils/index' 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 15 | outline: 16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 17 | secondary: 18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | ghost: 'hover:bg-accent hover:text-accent-foreground', 20 | link: 'text-primary underline-offset-4 hover:underline' 21 | }, 22 | size: { 23 | default: 'h-10 px-4 py-2', 24 | sm: 'h-9 rounded-md px-3', 25 | lg: 'h-11 rounded-md px-8', 26 | icon: 'h-10 w-10' 27 | } 28 | }, 29 | defaultVariants: { 30 | variant: 'default', 31 | size: 'default' 32 | } 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes<HTMLButtonElement>, 38 | VariantProps<typeof buttonVariants> { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : 'button' 45 | return ( 46 | <Comp 47 | className={cn(buttonVariants({ variant, size, className }))} 48 | ref={ref} 49 | {...props} 50 | /> 51 | ) 52 | } 53 | ) 54 | Button.displayName = 'Button' 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils/index' 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes<HTMLDivElement> 8 | >(({ className, ...props }, ref) => ( 9 | <div 10 | ref={ref} 11 | className={cn( 12 | 'rounded-lg border bg-card text-card-foreground shadow-sm', 13 | className 14 | )} 15 | {...props} 16 | /> 17 | )) 18 | Card.displayName = 'Card' 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes<HTMLDivElement> 23 | >(({ className, ...props }, ref) => ( 24 | <div 25 | ref={ref} 26 | className={cn('flex flex-col space-y-1.5 p-6', className)} 27 | {...props} 28 | /> 29 | )) 30 | CardHeader.displayName = 'CardHeader' 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes<HTMLHeadingElement> 35 | >(({ className, ...props }, ref) => ( 36 | <h3 37 | ref={ref} 38 | className={cn( 39 | 'text-2xl font-semibold leading-none tracking-tight', 40 | className 41 | )} 42 | {...props} 43 | /> 44 | )) 45 | CardTitle.displayName = 'CardTitle' 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes<HTMLParagraphElement> 50 | >(({ className, ...props }, ref) => ( 51 | <p 52 | ref={ref} 53 | className={cn('text-sm text-muted-foreground', className)} 54 | {...props} 55 | /> 56 | )) 57 | CardDescription.displayName = 'CardDescription' 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes<HTMLDivElement> 62 | >(({ className, ...props }, ref) => ( 63 | <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> 64 | )) 65 | CardContent.displayName = 'CardContent' 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes<HTMLDivElement> 70 | >(({ className, ...props }, ref) => ( 71 | <div 72 | ref={ref} 73 | className={cn('flex items-center p-6 pt-0', className)} 74 | {...props} 75 | /> 76 | )) 77 | CardFooter.displayName = 'CardFooter' 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox' 5 | import { Check } from 'lucide-react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef<typeof CheckboxPrimitive.Root>, 11 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> 12 | >(({ className, ...props }, ref) => ( 13 | <CheckboxPrimitive.Root 14 | ref={ref} 15 | className={cn( 16 | 'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', 17 | className 18 | )} 19 | {...props} 20 | > 21 | <CheckboxPrimitive.Indicator 22 | className={cn('flex items-center justify-center text-current')} 23 | > 24 | <Check className="h-4 w-4" /> 25 | </CheckboxPrimitive.Indicator> 26 | </CheckboxPrimitive.Root> 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /components/ui/icons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | function IconLogo({ className, ...props }: React.ComponentProps<'svg'>) { 6 | return ( 7 | <svg 8 | fill="currentColor" 9 | viewBox="0 0 256 256" 10 | role="img" 11 | xmlns="http://www.w3.org/2000/svg" 12 | className={cn('h-4 w-4', className)} 13 | {...props} 14 | > 15 | <circle cx="128" cy="128" r="128" fill="black"></circle> 16 | <circle cx="102" cy="128" r="18" fill="white"></circle> 17 | <circle cx="154" cy="128" r="18" fill="white"></circle> 18 | </svg> 19 | ) 20 | } 21 | 22 | export { IconLogo } 23 | -------------------------------------------------------------------------------- /components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button' 2 | export * from './tooltip' 3 | export * from './tooltip-button' 4 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils/index' 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes<HTMLInputElement> {} 7 | 8 | const Input = React.forwardRef<HTMLInputElement, InputProps>( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | <input 12 | type={type} 13 | className={cn( 14 | 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 15 | className 16 | )} 17 | ref={ref} 18 | {...props} 19 | /> 20 | ) 21 | } 22 | ) 23 | Input.displayName = 'Input' 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as LabelPrimitive from '@radix-ui/react-label' 5 | import { cva, type VariantProps } from 'class-variance-authority' 6 | 7 | import { cn } from '@/lib/utils/index' 8 | 9 | const labelVariants = cva( 10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef<typeof LabelPrimitive.Root>, 15 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & 16 | VariantProps<typeof labelVariants> 17 | >(({ className, ...props }, ref) => ( 18 | <LabelPrimitive.Root 19 | ref={ref} 20 | className={cn(labelVariants(), className)} 21 | {...props} 22 | /> 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from 'react' 2 | import ReactMarkdown, { Options } from 'react-markdown' 3 | 4 | export const MemoizedReactMarkdown: FC<Options> = memo( 5 | ReactMarkdown, 6 | (prevProps, nextProps) => 7 | prevProps.children === nextProps.children && 8 | prevProps.className === nextProps.className 9 | ) 10 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef<typeof PopoverPrimitive.Content>, 14 | React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | <PopoverPrimitive.Portal> 17 | <PopoverPrimitive.Content 18 | ref={ref} 19 | align={align} 20 | sideOffset={sideOffset} 21 | className={cn( 22 | "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 23 | className 24 | )} 25 | {...props} 26 | /> 27 | </PopoverPrimitive.Portal> 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /components/ui/resizable.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { GripVertical } from 'lucide-react' 4 | import * as ResizablePrimitive from 'react-resizable-panels' 5 | 6 | import { cn } from '@/lib/utils/index' 7 | 8 | const ResizablePanelGroup = ({ 9 | className, 10 | ...props 11 | }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( 12 | <ResizablePrimitive.PanelGroup 13 | className={cn( 14 | 'flex h-full w-full data-[panel-group-direction=vertical]:flex-col', 15 | className 16 | )} 17 | {...props} 18 | /> 19 | ) 20 | 21 | const ResizablePanel = ResizablePrimitive.Panel 22 | 23 | const ResizableHandle = ({ 24 | withHandle, 25 | className, 26 | ...props 27 | }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { 28 | withHandle?: boolean 29 | }) => ( 30 | <ResizablePrimitive.PanelResizeHandle 31 | className={cn( 32 | 'relative flex w-px items-center justify-center border border-border/50 hover:border-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90', 33 | className 34 | )} 35 | {...props} 36 | > 37 | {withHandle && ( 38 | <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> 39 | <GripVertical className="h-2.5 w-2.5" /> 40 | </div> 41 | )} 42 | </ResizablePrimitive.PanelResizeHandle> 43 | ) 44 | 45 | export { ResizableHandle, ResizablePanel, ResizablePanelGroup } 46 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 5 | 6 | import { cn } from '@/lib/utils/index' 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef<typeof SeparatorPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> 11 | >( 12 | ( 13 | { className, orientation = 'horizontal', decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | <SeparatorPrimitive.Root 17 | ref={ref} 18 | decorative={decorative} 19 | orientation={orientation} 20 | className={cn( 21 | 'shrink-0 bg-border', 22 | orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', 23 | className 24 | )} 25 | {...props} 26 | /> 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils/index' 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes<HTMLDivElement>) { 7 | return ( 8 | <div 9 | className={cn('animate-pulse rounded-md bg-muted', className)} 10 | {...props} 11 | /> 12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as SliderPrimitive from '@radix-ui/react-slider' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | const Slider = React.forwardRef< 9 | React.ElementRef<typeof SliderPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <SliderPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | 'relative flex w-full touch-none select-none items-center', 16 | className 17 | )} 18 | {...props} 19 | > 20 | <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> 21 | <SliderPrimitive.Range className="absolute h-full bg-primary" /> 22 | </SliderPrimitive.Track> 23 | <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> 24 | </SliderPrimitive.Root> 25 | )) 26 | Slider.displayName = SliderPrimitive.Root.displayName 27 | 28 | export { Slider } 29 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps<typeof Sonner> 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | <Sonner 13 | theme={theme as ToasterProps["theme"]} 14 | className="toaster group" 15 | toastOptions={{ 16 | classNames: { 17 | toast: 18 | "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", 19 | description: "group-[.toast]:text-muted-foreground", 20 | actionButton: 21 | "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", 22 | cancelButton: 23 | "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", 24 | }, 25 | }} 26 | {...props} 27 | /> 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | // Based on: https://github.com/vercel/ai/blob/main/examples/next-ai-rsc/components/llm-stocks/spinner.tsx 2 | 3 | import { cn } from '@/lib/utils' 4 | import { IconLogo } from './icons' 5 | 6 | interface SpinnerProps extends React.SVGProps<SVGSVGElement> {} 7 | 8 | export const Spinner = ({ className, ...props }: SpinnerProps) => ( 9 | <svg 10 | fill="none" 11 | stroke="currentColor" 12 | strokeWidth="1.5" 13 | viewBox="0 0 24 24" 14 | strokeLinecap="round" 15 | strokeLinejoin="round" 16 | xmlns="http://www.w3.org/2000/svg" 17 | className={cn('h-5 w-5 animate-spin stroke-zinc-400', className)} 18 | {...props} 19 | > 20 | <path d="M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12"></path> 21 | </svg> 22 | ) 23 | 24 | export const LogoSpinner = () => ( 25 | <div className="p-4 border border-background"> 26 | <IconLogo className="w-4 h-4 animate-spin" /> 27 | </div> 28 | ) 29 | -------------------------------------------------------------------------------- /components/ui/status-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react' 2 | import { ReactNode } from 'react' 3 | 4 | interface StatusIndicatorProps { 5 | icon: LucideIcon 6 | iconClassName?: string 7 | children: ReactNode 8 | } 9 | 10 | export function StatusIndicator({ 11 | icon: Icon, 12 | iconClassName, 13 | children 14 | }: StatusIndicatorProps) { 15 | return ( 16 | <span className="flex items-center gap-1 text-muted-foreground text-xs"> 17 | <Icon size={16} className={iconClassName} /> 18 | <span>{children}</span> 19 | </span> 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as SwitchPrimitives from '@radix-ui/react-switch' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef<typeof SwitchPrimitives.Root>, 10 | React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <SwitchPrimitives.Root 13 | className={cn( 14 | 'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input', 15 | className 16 | )} 17 | {...props} 18 | ref={ref} 19 | > 20 | <SwitchPrimitives.Thumb 21 | className={cn( 22 | 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0' 23 | )} 24 | /> 25 | </SwitchPrimitives.Root> 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} 7 | 8 | const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | <textarea 12 | className={cn( 13 | 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 14 | className 15 | )} 16 | ref={ref} 17 | {...props} 18 | /> 19 | ) 20 | } 21 | ) 22 | Textarea.displayName = 'Textarea' 23 | 24 | export { Textarea } 25 | -------------------------------------------------------------------------------- /components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as TogglePrimitive from '@radix-ui/react-toggle' 4 | import { cva, type VariantProps } from 'class-variance-authority' 5 | import * as React from 'react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const toggleVariants = cva( 10 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2', 11 | { 12 | variants: { 13 | variant: { 14 | default: 15 | 'bg-transparent data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', 16 | outline: 17 | 'border border-input hover:bg-accent hover:text-accent-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground' 18 | }, 19 | size: { 20 | default: 'h-10 px-3 min-w-10', 21 | sm: 'h-9 px-2.5 min-w-9', 22 | lg: 'h-11 px-5 min-w-11' 23 | } 24 | }, 25 | defaultVariants: { 26 | variant: 'default', 27 | size: 'default' 28 | } 29 | } 30 | ) 31 | 32 | const Toggle = React.forwardRef< 33 | React.ElementRef<typeof TogglePrimitive.Root>, 34 | React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & 35 | VariantProps<typeof toggleVariants> 36 | >(({ className, variant, size, ...props }, ref) => ( 37 | <TogglePrimitive.Root 38 | ref={ref} 39 | className={cn(toggleVariants({ variant, size, className }))} 40 | {...props} 41 | /> 42 | )) 43 | 44 | Toggle.displayName = TogglePrimitive.Root.displayName 45 | 46 | export { Toggle, toggleVariants } 47 | -------------------------------------------------------------------------------- /components/ui/tooltip-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { Button, ButtonProps } from '@/components/ui/button' 5 | import { 6 | Tooltip, 7 | TooltipContent, 8 | TooltipTrigger 9 | } from '@/components/ui/tooltip' 10 | 11 | interface TooltipButtonProps extends ButtonProps { 12 | /** 13 | * The tooltip content to display. 14 | * Can be a string or TooltipContent props. 15 | */ 16 | tooltipContent: string | Omit<React.ComponentPropsWithoutRef<typeof TooltipContent>, 'children'> & { 17 | children: React.ReactNode 18 | } 19 | /** 20 | * The content of the button. 21 | */ 22 | children: React.ReactNode 23 | } 24 | 25 | /** 26 | * A button component with a tooltip. 27 | */ 28 | export const TooltipButton = React.forwardRef< 29 | HTMLButtonElement, 30 | TooltipButtonProps 31 | >(({ tooltipContent, children, ...buttonProps }, ref) => { 32 | const tooltipProps = 33 | typeof tooltipContent === 'string' 34 | ? { children: <p>{tooltipContent}</p> } 35 | : tooltipContent 36 | 37 | return ( 38 | <Tooltip> 39 | <TooltipTrigger asChild> 40 | <Button ref={ref} {...buttonProps}> 41 | {children} 42 | </Button> 43 | </TooltipTrigger> 44 | <TooltipContent {...tooltipProps} /> 45 | </Tooltip> 46 | ) 47 | }) 48 | 49 | TooltipButton.displayName = 'TooltipButton' 50 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils/index" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef<typeof TooltipPrimitive.Content>, 16 | React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | <TooltipPrimitive.Content 19 | ref={ref} 20 | sideOffset={sideOffset} 21 | className={cn( 22 | "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 23 | className 24 | )} 25 | {...props} 26 | /> 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /components/update-password-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle 10 | } from '@/components/ui/card' 11 | import { Input } from '@/components/ui/input' 12 | import { Label } from '@/components/ui/label' 13 | import { createClient } from '@/lib/supabase/client' 14 | import { cn } from '@/lib/utils/index' 15 | import { useRouter } from 'next/navigation' 16 | import { useState } from 'react' 17 | 18 | export function UpdatePasswordForm({ 19 | className, 20 | ...props 21 | }: React.ComponentPropsWithoutRef<'div'>) { 22 | const [password, setPassword] = useState('') 23 | const [error, setError] = useState<string | null>(null) 24 | const [isLoading, setIsLoading] = useState(false) 25 | const router = useRouter() 26 | 27 | const handleForgotPassword = async (e: React.FormEvent) => { 28 | e.preventDefault() 29 | const supabase = createClient() 30 | setIsLoading(true) 31 | setError(null) 32 | 33 | try { 34 | const { error } = await supabase.auth.updateUser({ password }) 35 | if (error) throw error 36 | // Redirect to root and refresh to ensure server components get updated session. 37 | router.push('/') 38 | router.refresh() 39 | } catch (error: unknown) { 40 | setError(error instanceof Error ? error.message : 'An error occurred') 41 | } finally { 42 | setIsLoading(false) 43 | } 44 | } 45 | 46 | return ( 47 | <div className={cn('flex flex-col gap-6', className)} {...props}> 48 | <Card> 49 | <CardHeader> 50 | <CardTitle className="text-2xl">Reset Your Password</CardTitle> 51 | <CardDescription> 52 | Please enter your new password below. 53 | </CardDescription> 54 | </CardHeader> 55 | <CardContent> 56 | <form onSubmit={handleForgotPassword}> 57 | <div className="flex flex-col gap-6"> 58 | <div className="grid gap-2"> 59 | <Label htmlFor="password">New password</Label> 60 | <Input 61 | id="password" 62 | type="password" 63 | placeholder="New password" 64 | required 65 | value={password} 66 | onChange={e => setPassword(e.target.value)} 67 | /> 68 | </div> 69 | {error && <p className="text-sm text-red-500">{error}</p>} 70 | <Button type="submit" className="w-full" disabled={isLoading}> 71 | {isLoading ? 'Saving...' : 'Save new password'} 72 | </Button> 73 | </div> 74 | </form> 75 | </CardContent> 76 | </Card> 77 | </div> 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /components/user-message.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { Pencil } from 'lucide-react' 5 | import React, { useState } from 'react' 6 | import TextareaAutosize from 'react-textarea-autosize' 7 | import { CollapsibleMessage } from './collapsible-message' 8 | import { Button } from './ui/button' 9 | 10 | type UserMessageProps = { 11 | message: string 12 | messageId?: string 13 | onUpdateMessage?: (messageId: string, newContent: string) => Promise<void> 14 | } 15 | 16 | export const UserMessage: React.FC<UserMessageProps> = ({ 17 | message, 18 | messageId, 19 | onUpdateMessage 20 | }) => { 21 | const [isEditing, setIsEditing] = useState(false) 22 | const [editedContent, setEditedContent] = useState(message) 23 | 24 | const handleEditClick = (e: React.MouseEvent<HTMLButtonElement>) => { 25 | e.stopPropagation() 26 | setEditedContent(message) 27 | setIsEditing(true) 28 | } 29 | 30 | const handleCancelClick = () => { 31 | setIsEditing(false) 32 | } 33 | 34 | const handleSaveClick = async () => { 35 | if (!onUpdateMessage || !messageId) return 36 | 37 | setIsEditing(false) 38 | 39 | try { 40 | await onUpdateMessage(messageId, editedContent) 41 | } catch (error) { 42 | console.error('Failed to save message:', error) 43 | } 44 | } 45 | 46 | return ( 47 | <CollapsibleMessage role="user"> 48 | <div 49 | className="flex-1 break-words w-full group outline-none relative" 50 | tabIndex={0} 51 | > 52 | {isEditing ? ( 53 | <div className="flex flex-col gap-2"> 54 | <TextareaAutosize 55 | value={editedContent} 56 | onChange={e => setEditedContent(e.target.value)} 57 | autoFocus 58 | className="resize-none flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" 59 | minRows={2} 60 | maxRows={10} 61 | /> 62 | <div className="flex justify-end gap-2"> 63 | <Button variant="secondary" size="sm" onClick={handleCancelClick}> 64 | Cancel 65 | </Button> 66 | <Button size="sm" onClick={handleSaveClick}> 67 | Save 68 | </Button> 69 | </div> 70 | </div> 71 | ) : ( 72 | <div className="flex justify-between items-start"> 73 | <div className="flex-1">{message}</div> 74 | <div 75 | className={cn( 76 | 'absolute top-1 right-1 transition-opacity ml-2', 77 | 'opacity-0', 78 | 'group-focus-within:opacity-100', 79 | 'md:opacity-0', 80 | 'md:group-hover:opacity-100' 81 | )} 82 | > 83 | <Button 84 | variant="ghost" 85 | size="icon" 86 | className="rounded-full h-7 w-7" 87 | onClick={handleEditClick} 88 | > 89 | <Pencil className="size-3.5" /> 90 | </Button> 91 | </div> 92 | </div> 93 | )} 94 | </div> 95 | </CollapsibleMessage> 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /components/video-search-results.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 'use client' 3 | 4 | import { SerperSearchResultItem, SerperSearchResults } from '@/lib/types' 5 | import { VideoResultGrid } from './video-result-grid' 6 | 7 | export interface VideoSearchResultsProps { 8 | results: SerperSearchResults 9 | } 10 | 11 | export function VideoSearchResults({ results }: VideoSearchResultsProps) { 12 | const videos = results.videos.filter((video: SerperSearchResultItem) => { 13 | try { 14 | return new URL(video.link).pathname === '/watch' 15 | } catch (e) { 16 | console.error('Invalid video URL:', video.link) 17 | return false 18 | } 19 | }) 20 | 21 | const query = results.searchParameters?.q || '' 22 | 23 | if (!videos || videos.length === 0) { 24 | return <div className="text-muted-foreground">No videos found</div> 25 | } 26 | 27 | return <VideoResultGrid videos={videos} query={query} displayMode="chat" /> 28 | } 29 | -------------------------------------------------------------------------------- /components/video-search-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useArtifact } from '@/components/artifact/artifact-context' 4 | import { CHAT_ID } from '@/lib/constants' 5 | import type { SerperSearchResults } from '@/lib/types' 6 | import { useChat } from '@ai-sdk/react' 7 | import { ToolInvocation } from 'ai' 8 | import { CollapsibleMessage } from './collapsible-message' 9 | import { DefaultSkeleton } from './default-skeleton' 10 | import { Section, ToolArgsSection } from './section' 11 | import { VideoSearchResults } from './video-search-results' 12 | 13 | interface VideoSearchSectionProps { 14 | tool: ToolInvocation 15 | isOpen: boolean 16 | onOpenChange: (open: boolean) => void 17 | } 18 | 19 | export function VideoSearchSection({ 20 | tool, 21 | isOpen, 22 | onOpenChange 23 | }: VideoSearchSectionProps) { 24 | const { status } = useChat({ 25 | id: CHAT_ID 26 | }) 27 | const isLoading = status === 'submitted' || status === 'streaming' 28 | 29 | const isToolLoading = tool.state === 'call' 30 | const videoResults: SerperSearchResults = 31 | tool.state === 'result' ? tool.result : undefined 32 | const query = tool.args?.query as string | undefined 33 | 34 | const { open } = useArtifact() 35 | const header = ( 36 | <button 37 | type="button" 38 | onClick={() => open({ type: 'tool-invocation', toolInvocation: tool })} 39 | className="flex items-center justify-between w-full text-left rounded-md p-1 -ml-1" 40 | title="Open details" 41 | > 42 | <ToolArgsSection tool="videoSearch" number={videoResults?.videos?.length}> 43 | {query} 44 | </ToolArgsSection> 45 | </button> 46 | ) 47 | 48 | return ( 49 | <CollapsibleMessage 50 | role="assistant" 51 | isCollapsible={true} 52 | header={header} 53 | isOpen={isOpen} 54 | onOpenChange={onOpenChange} 55 | showIcon={false} 56 | > 57 | {!isLoading && videoResults ? ( 58 | <Section title="Videos"> 59 | <VideoSearchResults results={videoResults} /> 60 | </Section> 61 | ) : ( 62 | <DefaultSkeleton /> 63 | )} 64 | </CollapsibleMessage> 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Docker Compose configuration for the morphic-stack development environment 2 | 3 | name: morphic-stack 4 | services: 5 | morphic: 6 | image: ghcr.io/${GITHUB_REPOSITORY:-your-username/morphic}:latest 7 | command: bun start -H 0.0.0.0 8 | build: 9 | context: . 10 | dockerfile: Dockerfile 11 | cache_from: 12 | - morphic:builder 13 | - morphic:latest 14 | env_file: .env.local # Load environment variables 15 | ports: 16 | - '3000:3000' # Maps port 3000 on the host to port 3000 in the container. 17 | depends_on: 18 | - redis 19 | - searxng 20 | 21 | redis: 22 | image: redis:alpine 23 | ports: 24 | - '6379:6379' 25 | volumes: 26 | - redis_data:/data 27 | command: redis-server --appendonly yes 28 | 29 | searxng: 30 | image: searxng/searxng 31 | ports: 32 | - '${SEARXNG_PORT:-8080}:8080' 33 | env_file: .env.local # can remove if you want to use env variables or in settings.yml 34 | volumes: 35 | - ./searxng-limiter.toml:/etc/searxng/limiter.toml 36 | - ./searxng-settings.yml:/etc/searxng/settings.yml 37 | - searxng_data:/data 38 | 39 | volumes: 40 | redis_data: 41 | searxng_data: 42 | -------------------------------------------------------------------------------- /hooks/use-current-user-image.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@/lib/supabase/client' 2 | import { useEffect, useState } from 'react' 3 | 4 | export const useCurrentUserImage = () => { 5 | const [image, setImage] = useState<string | null>(null) 6 | 7 | useEffect(() => { 8 | const fetchUserImage = async () => { 9 | const { data, error } = await createClient().auth.getSession() 10 | if (error) { 11 | console.error(error) 12 | } 13 | 14 | setImage(data.session?.user.user_metadata.avatar_url ?? null) 15 | } 16 | fetchUserImage() 17 | }, []) 18 | 19 | return image 20 | } 21 | -------------------------------------------------------------------------------- /hooks/use-current-user-name.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@/lib/supabase/client' 2 | import { useEffect, useState } from 'react' 3 | 4 | export const useCurrentUserName = () => { 5 | const [name, setName] = useState<string | null>(null) 6 | 7 | useEffect(() => { 8 | const fetchProfileName = async () => { 9 | const { data, error } = await createClient().auth.getSession() 10 | if (error) { 11 | console.error(error) 12 | } 13 | 14 | setName(data.session?.user.user_metadata.full_name ?? '?') 15 | } 16 | 17 | fetchProfileName() 18 | }, []) 19 | 20 | return name || '?' 21 | } 22 | -------------------------------------------------------------------------------- /hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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 | -------------------------------------------------------------------------------- /lib/agents/generate-related-questions.ts: -------------------------------------------------------------------------------- 1 | import { relatedSchema } from '@/lib/schema/related' 2 | import { CoreMessage, generateObject } from 'ai' 3 | import { 4 | getModel, 5 | getToolCallModel, 6 | isToolCallSupported 7 | } from '../utils/registry' 8 | 9 | export async function generateRelatedQuestions( 10 | messages: CoreMessage[], 11 | model: string 12 | ) { 13 | const lastMessages = messages.slice(-1).map(message => ({ 14 | ...message, 15 | role: 'user' 16 | })) as CoreMessage[] 17 | 18 | const supportedModel = isToolCallSupported(model) 19 | const currentModel = supportedModel 20 | ? getModel(model) 21 | : getToolCallModel(model) 22 | 23 | const result = await generateObject({ 24 | model: currentModel, 25 | system: `As a professional web researcher, your task is to generate a set of three queries that explore the subject matter more deeply, building upon the initial query and the information uncovered in its search results. 26 | 27 | For instance, if the original query was "Starship's third test flight key milestones", your output should follow this format: 28 | 29 | Aim to create queries that progressively delve into more specific aspects, implications, or adjacent topics related to the initial query. The goal is to anticipate the user's potential information needs and guide them towards a more comprehensive understanding of the subject matter. 30 | Please match the language of the response to the user's language.`, 31 | messages: lastMessages, 32 | schema: relatedSchema 33 | }) 34 | 35 | return result 36 | } 37 | -------------------------------------------------------------------------------- /lib/agents/manual-researcher.ts: -------------------------------------------------------------------------------- 1 | import { CoreMessage, smoothStream, streamText } from 'ai' 2 | import { getModel } from '../utils/registry' 3 | 4 | const BASE_SYSTEM_PROMPT = ` 5 | Instructions: 6 | 7 | You are a helpful AI assistant providing accurate information. 8 | 9 | 1. Provide comprehensive and detailed responses to user questions 10 | 2. Use markdown to structure your responses with appropriate headings 11 | 3. Acknowledge when you are uncertain about specific details 12 | 4. Focus on maintaining high accuracy in your responses 13 | ` 14 | 15 | const SEARCH_ENABLED_PROMPT = ` 16 | ${BASE_SYSTEM_PROMPT} 17 | 18 | When analyzing search results: 19 | 1. Analyze the provided search results carefully to answer the user's question 20 | 2. Always cite sources using the [number](url) format, matching the order of search results 21 | 3. If multiple sources are relevant, include all of them using comma-separated citations 22 | 4. Only use information that has a URL available for citation 23 | 5. If the search results don't contain relevant information, acknowledge this and provide a general response 24 | 25 | Citation Format: 26 | [number](url) 27 | ` 28 | 29 | const SEARCH_DISABLED_PROMPT = ` 30 | ${BASE_SYSTEM_PROMPT} 31 | 32 | Important: 33 | 1. Provide responses based on your general knowledge 34 | 2. Be clear about any limitations in your knowledge 35 | 3. Suggest when searching for additional information might be beneficial 36 | ` 37 | 38 | interface ManualResearcherConfig { 39 | messages: CoreMessage[] 40 | model: string 41 | isSearchEnabled?: boolean 42 | } 43 | 44 | type ManualResearcherReturn = Parameters<typeof streamText>[0] 45 | 46 | export function manualResearcher({ 47 | messages, 48 | model, 49 | isSearchEnabled = true 50 | }: ManualResearcherConfig): ManualResearcherReturn { 51 | try { 52 | const currentDate = new Date().toLocaleString() 53 | const systemPrompt = isSearchEnabled 54 | ? SEARCH_ENABLED_PROMPT 55 | : SEARCH_DISABLED_PROMPT 56 | 57 | return { 58 | model: getModel(model), 59 | system: `${systemPrompt}\nCurrent date and time: ${currentDate}`, 60 | messages, 61 | temperature: 0.6, 62 | topP: 1, 63 | topK: 40, 64 | experimental_transform: smoothStream() 65 | } 66 | } catch (error) { 67 | console.error('Error in manualResearcher:', error) 68 | throw error 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/agents/researcher.ts: -------------------------------------------------------------------------------- 1 | import { CoreMessage, smoothStream, streamText } from 'ai' 2 | import { createQuestionTool } from '../tools/question' 3 | import { retrieveTool } from '../tools/retrieve' 4 | import { createSearchTool } from '../tools/search' 5 | import { createVideoSearchTool } from '../tools/video-search' 6 | import { getModel } from '../utils/registry' 7 | 8 | const SYSTEM_PROMPT = ` 9 | Instructions: 10 | 11 | You are a helpful AI assistant with access to real-time web search, content retrieval, video search capabilities, and the ability to ask clarifying questions. 12 | 13 | When asked a question, you should: 14 | 1. First, determine if you need more information to properly understand the user's query 15 | 2. **If the query is ambiguous or lacks specific details, use the ask_question tool to create a structured question with relevant options** 16 | 3. If you have enough information, search for relevant information using the search tool when needed 17 | 4. Use the retrieve tool to get detailed content from specific URLs 18 | 5. Use the video search tool when looking for video content 19 | 6. Analyze all search results to provide accurate, up-to-date information 20 | 7. Always cite sources using the [number](url) format, matching the order of search results. If multiple sources are relevant, include all of them, and comma separate them. Only use information that has a URL available for citation. 21 | 8. If results are not relevant or helpful, rely on your general knowledge 22 | 9. Provide comprehensive and detailed responses based on search results, ensuring thorough coverage of the user's question 23 | 10. Use markdown to structure your responses. Use headings to break up the content into sections. 24 | 11. **Use the retrieve tool only with user-provided URLs.** 25 | 26 | When using the ask_question tool: 27 | - Create clear, concise questions 28 | - Provide relevant predefined options 29 | - Enable free-form input when appropriate 30 | - Match the language to the user's language (except option values which must be in English) 31 | 32 | Citation Format: 33 | [number](url) 34 | ` 35 | 36 | type ResearcherReturn = Parameters<typeof streamText>[0] 37 | 38 | export function researcher({ 39 | messages, 40 | model, 41 | searchMode 42 | }: { 43 | messages: CoreMessage[] 44 | model: string 45 | searchMode: boolean 46 | }): ResearcherReturn { 47 | try { 48 | const currentDate = new Date().toLocaleString() 49 | 50 | // Create model-specific tools 51 | const searchTool = createSearchTool(model) 52 | const videoSearchTool = createVideoSearchTool(model) 53 | const askQuestionTool = createQuestionTool(model) 54 | 55 | return { 56 | model: getModel(model), 57 | system: `${SYSTEM_PROMPT}\nCurrent date and time: ${currentDate}`, 58 | messages, 59 | tools: { 60 | search: searchTool, 61 | retrieve: retrieveTool, 62 | videoSearch: videoSearchTool, 63 | ask_question: askQuestionTool 64 | }, 65 | experimental_activeTools: searchMode 66 | ? ['search', 'retrieve', 'videoSearch', 'ask_question'] 67 | : [], 68 | maxSteps: searchMode ? 5 : 1, 69 | experimental_transform: smoothStream() 70 | } 71 | } catch (error) { 72 | console.error('Error in chatResearcher:', error) 73 | throw error 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/auth/get-current-user.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@/lib/supabase/server' 2 | 3 | export async function getCurrentUser() { 4 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL 5 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY 6 | 7 | if (!supabaseUrl || !supabaseAnonKey) { 8 | return null // Supabase is not configured 9 | } 10 | 11 | const supabase = await createClient() 12 | const { data } = await supabase.auth.getUser() 13 | return data.user ?? null 14 | } 15 | 16 | export async function getCurrentUserId() { 17 | const user = await getCurrentUser() 18 | return user?.id ?? 'anonymous' 19 | } 20 | -------------------------------------------------------------------------------- /lib/config/models.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '@/lib/types/models' 2 | import { getBaseUrl } from '@/lib/utils/url' 3 | import defaultModels from './default-models.json' 4 | 5 | export function validateModel(model: any): model is Model { 6 | return ( 7 | typeof model.id === 'string' && 8 | typeof model.name === 'string' && 9 | typeof model.provider === 'string' && 10 | typeof model.providerId === 'string' && 11 | typeof model.enabled === 'boolean' && 12 | (model.toolCallType === 'native' || model.toolCallType === 'manual') && 13 | (model.toolCallModel === undefined || 14 | typeof model.toolCallModel === 'string') 15 | ) 16 | } 17 | 18 | export async function getModels(): Promise<Model[]> { 19 | try { 20 | // Get the base URL using the centralized utility function 21 | const baseUrlObj = await getBaseUrl() 22 | 23 | // Construct the models.json URL 24 | const modelUrl = new URL('/config/models.json', baseUrlObj) 25 | console.log('Attempting to fetch models from:', modelUrl.toString()) 26 | 27 | try { 28 | const response = await fetch(modelUrl, { 29 | cache: 'no-store', 30 | headers: { 31 | Accept: 'application/json' 32 | } 33 | }) 34 | 35 | if (!response.ok) { 36 | console.warn( 37 | `HTTP error when fetching models: ${response.status} ${response.statusText}` 38 | ) 39 | throw new Error(`HTTP error! status: ${response.status}`) 40 | } 41 | 42 | const text = await response.text() 43 | 44 | // Check if the response starts with HTML doctype 45 | if (text.trim().toLowerCase().startsWith('<!doctype')) { 46 | console.warn('Received HTML instead of JSON when fetching models') 47 | throw new Error('Received HTML instead of JSON') 48 | } 49 | 50 | const config = JSON.parse(text) 51 | if (Array.isArray(config.models) && config.models.every(validateModel)) { 52 | console.log('Successfully loaded models from URL') 53 | return config.models 54 | } 55 | } catch (error: any) { 56 | // Fallback to default models if fetch fails 57 | console.warn( 58 | 'Fetch failed, falling back to default models:', 59 | error.message || 'Unknown error' 60 | ) 61 | 62 | if ( 63 | Array.isArray(defaultModels.models) && 64 | defaultModels.models.every(validateModel) 65 | ) { 66 | console.log('Successfully loaded default models') 67 | return defaultModels.models 68 | } 69 | } 70 | } catch (error) { 71 | console.warn('Failed to load models:', error) 72 | } 73 | 74 | // Last resort: return empty array 75 | console.warn('All attempts to load models failed, returning empty array') 76 | return [] 77 | } 78 | 79 | -------------------------------------------------------------------------------- /lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const CHAT_ID = 'search' as const 2 | -------------------------------------------------------------------------------- /lib/hooks/use-copy-to-clipboard.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | export interface useCopyToClipboardProps { 6 | timeout?: number 7 | } 8 | 9 | export function useCopyToClipboard({ 10 | timeout = 2000 11 | }: useCopyToClipboardProps) { 12 | const [isCopied, setIsCopied] = useState<Boolean>(false) 13 | 14 | const copyToClipboard = (value: string) => { 15 | if (typeof window === 'undefined' || !navigator.clipboard?.writeText) { 16 | return 17 | } 18 | 19 | if (!value) { 20 | return 21 | } 22 | 23 | navigator.clipboard.writeText(value).then(() => { 24 | setIsCopied(true) 25 | 26 | setTimeout(() => { 27 | setIsCopied(false) 28 | }, timeout) 29 | }) 30 | } 31 | 32 | return { isCopied, copyToClipboard } 33 | } 34 | -------------------------------------------------------------------------------- /lib/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | 5 | /** 6 | * Custom hook to track media query matches. 7 | * @param query - The media query string (e.g., '(max-width: 767px)'). 8 | * @returns boolean - True if the media query matches, false otherwise. 9 | */ 10 | export function useMediaQuery(query: string): boolean { 11 | const [matches, setMatches] = useState(false) 12 | 13 | useEffect(() => { 14 | // Ensure window is available (client-side only) 15 | if (typeof window === 'undefined') { 16 | return 17 | } 18 | const mql = window.matchMedia(query) 19 | const handler = (e: MediaQueryListEvent) => setMatches(e.matches) 20 | 21 | // Set initial state 22 | setMatches(mql.matches) 23 | 24 | // Add listener 25 | mql.addEventListener('change', handler) 26 | 27 | // Cleanup listener on unmount 28 | return () => mql.removeEventListener('change', handler) 29 | }, [query]) 30 | 31 | return matches 32 | } 33 | -------------------------------------------------------------------------------- /lib/schema/question.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | // Standard schema with optional fields for inputLabel and inputPlaceholder 4 | export const questionSchema = z.object({ 5 | question: z.string().describe('The main question to ask the user'), 6 | options: z 7 | .array( 8 | z.object({ 9 | value: z.string().describe('Option identifier (always in English)'), 10 | label: z.string().describe('Display text for the option') 11 | }) 12 | ) 13 | .describe('List of predefined options'), 14 | allowsInput: z.boolean().describe('Whether to allow free-form text input'), 15 | inputLabel: z.string().optional().describe('Label for free-form input field'), 16 | inputPlaceholder: z 17 | .string() 18 | .optional() 19 | .describe('Placeholder text for input field') 20 | }) 21 | 22 | // Strict schema with all fields required, for specific models like o3-mini 23 | export const strictQuestionSchema = z.object({ 24 | question: z.string().describe('The main question to ask the user'), 25 | options: z 26 | .array( 27 | z.object({ 28 | value: z.string().describe('Option identifier (always in English)'), 29 | label: z.string().describe('Display text for the option') 30 | }) 31 | ) 32 | .describe('List of predefined options'), 33 | allowsInput: z.boolean().describe('Whether to allow free-form text input'), 34 | inputLabel: z.string().describe('Label for free-form input field'), 35 | inputPlaceholder: z.string().describe('Placeholder text for input field') 36 | }) 37 | 38 | /** 39 | * Returns the appropriate question schema based on the full model name. 40 | * Uses the strict schema for OpenAI models starting with 'o'. 41 | */ 42 | export function getQuestionSchemaForModel(fullModel: string) { 43 | const [provider, modelName] = fullModel?.split(':') ?? [] 44 | const useStrictSchema = 45 | (provider === 'openai' || provider === 'azure') && 46 | modelName?.startsWith('o') 47 | return useStrictSchema ? strictQuestionSchema : questionSchema 48 | } 49 | -------------------------------------------------------------------------------- /lib/schema/related.tsx: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'ai' 2 | import { z } from 'zod' 3 | 4 | export const relatedSchema = z.object({ 5 | items: z 6 | .array( 7 | z.object({ 8 | query: z.string() 9 | }) 10 | ) 11 | .length(3) 12 | }) 13 | export type PartialRelated = DeepPartial<typeof relatedSchema> 14 | 15 | export type Related = z.infer<typeof relatedSchema> 16 | -------------------------------------------------------------------------------- /lib/schema/retrieve.tsx: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'ai' 2 | import { z } from 'zod' 3 | 4 | export const retrieveSchema = z.object({ 5 | url: z.string().describe('The url to retrieve') 6 | }) 7 | 8 | export type PartialInquiry = DeepPartial<typeof retrieveSchema> 9 | -------------------------------------------------------------------------------- /lib/schema/search.tsx: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'ai' 2 | import { z } from 'zod' 3 | 4 | export const searchSchema = z.object({ 5 | query: z.string().describe('The query to search for'), 6 | max_results: z 7 | .number() 8 | .optional() 9 | .describe('The maximum number of results to return. default is 20'), 10 | search_depth: z 11 | .string() 12 | .optional() 13 | .describe( 14 | 'The depth of the search. Allowed values are "basic" or "advanced"' 15 | ), 16 | include_domains: z 17 | .array(z.string()) 18 | .optional() 19 | .describe( 20 | 'A list of domains to specifically include in the search results. Default is None, which includes all domains.' 21 | ), 22 | exclude_domains: z 23 | .array(z.string()) 24 | .optional() 25 | .describe( 26 | "A list of domains to specifically exclude from the search results. Default is None, which doesn't exclude any domains." 27 | ) 28 | }) 29 | 30 | // Strict schema with all fields required 31 | export const strictSearchSchema = z.object({ 32 | query: z.string().describe('The query to search for'), 33 | max_results: z 34 | .number() 35 | .describe('The maximum number of results to return. default is 20'), 36 | search_depth: z 37 | .enum(['basic', 'advanced']) 38 | .describe('The depth of the search'), 39 | include_domains: z 40 | .array(z.string()) 41 | .describe( 42 | 'A list of domains to specifically include in the search results. Default is None, which includes all domains.' 43 | ), 44 | exclude_domains: z 45 | .array(z.string()) 46 | .describe( 47 | "A list of domains to specifically exclude from the search results. Default is None, which doesn't exclude any domains." 48 | ) 49 | }) 50 | 51 | /** 52 | * Returns the appropriate search schema based on the full model name. 53 | * Uses the strict schema for OpenAI models starting with 'o'. 54 | */ 55 | export function getSearchSchemaForModel(fullModel: string) { 56 | const [provider, modelName] = fullModel?.split(':') ?? [] 57 | const useStrictSchema = 58 | (provider === 'openai' || provider === 'azure') && 59 | modelName?.startsWith('o') 60 | 61 | // Ensure search_depth is an enum for the strict schema 62 | if (useStrictSchema) { 63 | return strictSearchSchema 64 | } else { 65 | // For the standard schema, keep search_depth as optional string 66 | return searchSchema 67 | } 68 | } 69 | 70 | export type PartialInquiry = DeepPartial<typeof searchSchema> 71 | -------------------------------------------------------------------------------- /lib/streaming/create-tool-calling-stream.ts: -------------------------------------------------------------------------------- 1 | import { researcher } from '@/lib/agents/researcher' 2 | import { 3 | convertToCoreMessages, 4 | CoreMessage, 5 | createDataStreamResponse, 6 | DataStreamWriter, 7 | streamText 8 | } from 'ai' 9 | import { getMaxAllowedTokens, truncateMessages } from '../utils/context-window' 10 | import { isReasoningModel } from '../utils/registry' 11 | import { handleStreamFinish } from './handle-stream-finish' 12 | import { BaseStreamConfig } from './types' 13 | 14 | // Function to check if a message contains ask_question tool invocation 15 | function containsAskQuestionTool(message: CoreMessage) { 16 | // For CoreMessage format, we check the content array 17 | if (message.role !== 'assistant' || !Array.isArray(message.content)) { 18 | return false 19 | } 20 | 21 | // Check if any content item is a tool-call with ask_question tool 22 | return message.content.some( 23 | item => item.type === 'tool-call' && item.toolName === 'ask_question' 24 | ) 25 | } 26 | 27 | export function createToolCallingStreamResponse(config: BaseStreamConfig) { 28 | return createDataStreamResponse({ 29 | execute: async (dataStream: DataStreamWriter) => { 30 | const { messages, model, chatId, searchMode, userId } = config 31 | const modelId = `${model.providerId}:${model.id}` 32 | 33 | try { 34 | const coreMessages = convertToCoreMessages(messages) 35 | const truncatedMessages = truncateMessages( 36 | coreMessages, 37 | getMaxAllowedTokens(model) 38 | ) 39 | 40 | let researcherConfig = await researcher({ 41 | messages: truncatedMessages, 42 | model: modelId, 43 | searchMode 44 | }) 45 | 46 | const result = streamText({ 47 | ...researcherConfig, 48 | onFinish: async result => { 49 | // Check if the last message contains an ask_question tool invocation 50 | const shouldSkipRelatedQuestions = 51 | isReasoningModel(modelId) || 52 | (result.response.messages.length > 0 && 53 | containsAskQuestionTool( 54 | result.response.messages[ 55 | result.response.messages.length - 1 56 | ] as CoreMessage 57 | )) 58 | 59 | await handleStreamFinish({ 60 | responseMessages: result.response.messages, 61 | originalMessages: messages, 62 | model: modelId, 63 | chatId, 64 | dataStream, 65 | userId, 66 | skipRelatedQuestions: shouldSkipRelatedQuestions 67 | }) 68 | } 69 | }) 70 | 71 | result.mergeIntoDataStream(dataStream) 72 | } catch (error) { 73 | console.error('Stream execution error:', error) 74 | throw error 75 | } 76 | }, 77 | onError: error => { 78 | // console.error('Stream error:', error) 79 | return error instanceof Error ? error.message : String(error) 80 | } 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /lib/streaming/handle-stream-finish.ts: -------------------------------------------------------------------------------- 1 | import { getChat, saveChat } from '@/lib/actions/chat' 2 | import { generateRelatedQuestions } from '@/lib/agents/generate-related-questions' 3 | import { ExtendedCoreMessage } from '@/lib/types' 4 | import { convertToExtendedCoreMessages } from '@/lib/utils' 5 | import { CoreMessage, DataStreamWriter, JSONValue, Message } from 'ai' 6 | 7 | interface HandleStreamFinishParams { 8 | responseMessages: CoreMessage[] 9 | originalMessages: Message[] 10 | model: string 11 | chatId: string 12 | dataStream: DataStreamWriter 13 | userId: string 14 | skipRelatedQuestions?: boolean 15 | annotations?: ExtendedCoreMessage[] 16 | } 17 | 18 | export async function handleStreamFinish({ 19 | responseMessages, 20 | originalMessages, 21 | model, 22 | chatId, 23 | dataStream, 24 | userId, 25 | skipRelatedQuestions = false, 26 | annotations = [] 27 | }: HandleStreamFinishParams) { 28 | try { 29 | const extendedCoreMessages = convertToExtendedCoreMessages(originalMessages) 30 | let allAnnotations = [...annotations] 31 | 32 | if (!skipRelatedQuestions) { 33 | // Notify related questions loading 34 | const relatedQuestionsAnnotation: JSONValue = { 35 | type: 'related-questions', 36 | data: { items: [] } 37 | } 38 | dataStream.writeMessageAnnotation(relatedQuestionsAnnotation) 39 | 40 | // Generate related questions 41 | const relatedQuestions = await generateRelatedQuestions( 42 | responseMessages, 43 | model 44 | ) 45 | 46 | // Create and add related questions annotation 47 | const updatedRelatedQuestionsAnnotation: ExtendedCoreMessage = { 48 | role: 'data', 49 | content: { 50 | type: 'related-questions', 51 | data: relatedQuestions.object 52 | } as JSONValue 53 | } 54 | 55 | dataStream.writeMessageAnnotation( 56 | updatedRelatedQuestionsAnnotation.content as JSONValue 57 | ) 58 | allAnnotations.push(updatedRelatedQuestionsAnnotation) 59 | } 60 | 61 | // Create the message to save 62 | const generatedMessages = [ 63 | ...extendedCoreMessages, 64 | ...responseMessages.slice(0, -1), 65 | ...allAnnotations, // Add annotations before the last message 66 | ...responseMessages.slice(-1) 67 | ] as ExtendedCoreMessage[] 68 | 69 | if (process.env.ENABLE_SAVE_CHAT_HISTORY !== 'true') { 70 | return 71 | } 72 | 73 | // Get the chat from the database if it exists, otherwise create a new one 74 | const savedChat = (await getChat(chatId, userId)) ?? { 75 | messages: [], 76 | createdAt: new Date(), 77 | userId: userId, 78 | path: `/search/${chatId}`, 79 | title: originalMessages[0].content, 80 | id: chatId 81 | } 82 | 83 | // Save chat with complete response and related questions 84 | await saveChat( 85 | { 86 | ...savedChat, 87 | messages: generatedMessages 88 | }, 89 | userId 90 | ).catch(error => { 91 | console.error('Failed to save chat:', error) 92 | throw new Error('Failed to save chat history') 93 | }) 94 | } catch (error) { 95 | console.error('Error in handleStreamFinish:', error) 96 | throw error 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/streaming/parse-tool-call.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export interface ToolCall<T = unknown> { 4 | tool: string 5 | parameters?: T 6 | } 7 | 8 | function getTagContent(xml: string, tag: string): string { 9 | const match = xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`, 's')) 10 | return match ? match[1].trim() : '' 11 | } 12 | 13 | export function parseToolCallXml<T>( 14 | xml: string, 15 | schema?: z.ZodType<T> 16 | ): ToolCall<T> { 17 | const toolCallContent = getTagContent(xml, 'tool_call') 18 | if (!toolCallContent) { 19 | console.warn('No tool_call tag found in response') 20 | return { tool: '' } 21 | } 22 | 23 | const tool = getTagContent(toolCallContent, 'tool') 24 | if (!tool) return { tool: '' } 25 | 26 | const parametersXml = getTagContent(toolCallContent, 'parameters') 27 | if (!parametersXml || !schema) return { tool } 28 | 29 | try { 30 | // Extract all parameter values using tag names from schema 31 | const rawParameters: Record<string, string> = {} 32 | if (schema instanceof z.ZodObject) { 33 | Object.keys(schema.shape).forEach(key => { 34 | const value = getTagContent(parametersXml, key) 35 | if (value) rawParameters[key] = value 36 | }) 37 | } 38 | 39 | // Parse parameters using the provided schema 40 | const parameters = schema.parse({ 41 | ...rawParameters, 42 | // Convert comma-separated strings to arrays for array fields with default empty arrays 43 | include_domains: 44 | rawParameters.include_domains 45 | ?.split(',') 46 | .map(d => d.trim()) 47 | .filter(Boolean) ?? [], 48 | exclude_domains: 49 | rawParameters.exclude_domains 50 | ?.split(',') 51 | .map(d => d.trim()) 52 | .filter(Boolean) ?? [], 53 | // Convert string to number for numeric fields 54 | max_results: rawParameters.max_results 55 | ? parseInt(rawParameters.max_results, 10) 56 | : undefined 57 | }) 58 | 59 | return { tool, parameters } 60 | } catch (error) { 61 | console.error('Failed to parse parameters:', error) 62 | return { tool } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/streaming/types.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'ai' 2 | import { Model } from '../types/models' 3 | 4 | export interface BaseStreamConfig { 5 | messages: Message[] 6 | model: Model 7 | chatId: string 8 | searchMode: boolean 9 | userId: string 10 | } 11 | -------------------------------------------------------------------------------- /lib/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from '@supabase/ssr' 2 | 3 | export function createClient() { 4 | return createBrowserClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /lib/supabase/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { NextResponse, type NextRequest } from 'next/server' 3 | 4 | export async function updateSession(request: NextRequest) { 5 | let supabaseResponse = NextResponse.next({ 6 | request 7 | }) 8 | 9 | const supabase = createServerClient( 10 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 12 | { 13 | cookies: { 14 | getAll() { 15 | return request.cookies.getAll() 16 | }, 17 | setAll(cookiesToSet) { 18 | cookiesToSet.forEach(({ name, value }) => 19 | request.cookies.set(name, value) 20 | ) 21 | supabaseResponse = NextResponse.next({ 22 | request 23 | }) 24 | cookiesToSet.forEach(({ name, value, options }) => 25 | supabaseResponse.cookies.set(name, value, options) 26 | ) 27 | } 28 | } 29 | } 30 | ) 31 | 32 | // Do not run code between createServerClient and 33 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug 34 | // issues with users being randomly logged out. 35 | 36 | // IMPORTANT: DO NOT REMOVE auth.getUser() 37 | 38 | const { 39 | data: { user } 40 | } = await supabase.auth.getUser() 41 | 42 | // Define public paths that don't require authentication 43 | const publicPaths = [ 44 | '/', // Root path 45 | '/auth', // Auth-related pages 46 | '/share', // Share pages 47 | '/api' // API routes 48 | // Add other public paths here if needed 49 | ] 50 | 51 | const pathname = request.nextUrl.pathname 52 | 53 | // Redirect to login if the user is not authenticated and the path is not public 54 | if (!user && !publicPaths.some(path => pathname.startsWith(path))) { 55 | // no user, potentially respond by redirecting the user to the login page 56 | const url = request.nextUrl.clone() 57 | url.pathname = '/auth/login' 58 | return NextResponse.redirect(url) 59 | } 60 | 61 | // IMPORTANT: You *must* return the supabaseResponse object as it is. 62 | // If you're creating a new response object with NextResponse.next() make sure to: 63 | // 1. Pass the request in it, like so: 64 | // const myNewResponse = NextResponse.next({ request }) 65 | // 2. Copy over the cookies, like so: 66 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) 67 | // 3. Change the myNewResponse object to fit your needs, but avoid changing 68 | // the cookies! 69 | // 4. Finally: 70 | // return myNewResponse 71 | // If this is not done, you may be causing the browser and server to go out 72 | // of sync and terminate the user's session prematurely! 73 | 74 | return supabaseResponse 75 | } 76 | -------------------------------------------------------------------------------- /lib/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { cookies } from 'next/headers' 3 | 4 | export async function createClient() { 5 | const cookieStore = await cookies() 6 | 7 | return createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 10 | { 11 | cookies: { 12 | getAll() { 13 | return cookieStore.getAll() 14 | }, 15 | setAll(cookiesToSet) { 16 | try { 17 | cookiesToSet.forEach(({ name, value, options }) => 18 | cookieStore.set(name, value, options) 19 | ) 20 | } catch { 21 | // The `setAll` method was called from a Server Component. 22 | // This can be ignored if you have middleware refreshing 23 | // user sessions. 24 | } 25 | }, 26 | }, 27 | } 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /lib/tools/question.ts: -------------------------------------------------------------------------------- 1 | import { getQuestionSchemaForModel } from '@/lib/schema/question' 2 | import { tool } from 'ai' 3 | 4 | /** 5 | * Creates a question tool with the appropriate schema for the specified model. 6 | */ 7 | export function createQuestionTool(fullModel: string) { 8 | return tool({ 9 | description: 10 | 'Ask a clarifying question with multiple options when more information is needed', 11 | parameters: getQuestionSchemaForModel(fullModel) 12 | // execute function removed to enable frontend confirmation 13 | }) 14 | } 15 | 16 | // Default export for backward compatibility, using a default model 17 | export const askQuestionTool = createQuestionTool('openai:gpt-4o-mini') 18 | -------------------------------------------------------------------------------- /lib/tools/retrieve.ts: -------------------------------------------------------------------------------- 1 | import { tool } from 'ai' 2 | import { retrieveSchema } from '@/lib/schema/retrieve' 3 | import { SearchResults as SearchResultsType } from '@/lib/types' 4 | 5 | const CONTENT_CHARACTER_LIMIT = 10000 6 | 7 | async function fetchJinaReaderData( 8 | url: string 9 | ): Promise<SearchResultsType | null> { 10 | try { 11 | const response = await fetch(`https://r.jina.ai/${url}`, { 12 | method: 'GET', 13 | headers: { 14 | Accept: 'application/json', 15 | 'X-With-Generated-Alt': 'true' 16 | } 17 | }) 18 | const json = await response.json() 19 | if (!json.data || json.data.length === 0) { 20 | return null 21 | } 22 | 23 | const content = json.data.content.slice(0, CONTENT_CHARACTER_LIMIT) 24 | 25 | return { 26 | results: [ 27 | { 28 | title: json.data.title, 29 | content, 30 | url: json.data.url 31 | } 32 | ], 33 | query: '', 34 | images: [] 35 | } 36 | } catch (error) { 37 | console.error('Jina Reader API error:', error) 38 | return null 39 | } 40 | } 41 | 42 | async function fetchTavilyExtractData( 43 | url: string 44 | ): Promise<SearchResultsType | null> { 45 | try { 46 | const apiKey = process.env.TAVILY_API_KEY 47 | const response = await fetch('https://api.tavily.com/extract', { 48 | method: 'POST', 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: JSON.stringify({ api_key: apiKey, urls: [url] }) 53 | }) 54 | const json = await response.json() 55 | if (!json.results || json.results.length === 0) { 56 | return null 57 | } 58 | 59 | const result = json.results[0] 60 | const content = result.raw_content.slice(0, CONTENT_CHARACTER_LIMIT) 61 | 62 | return { 63 | results: [ 64 | { 65 | title: content.slice(0, 100), 66 | content, 67 | url: result.url 68 | } 69 | ], 70 | query: '', 71 | images: [] 72 | } 73 | } catch (error) { 74 | console.error('Tavily Extract API error:', error) 75 | return null 76 | } 77 | } 78 | 79 | export const retrieveTool = tool({ 80 | description: 'Retrieve content from the web', 81 | parameters: retrieveSchema, 82 | execute: async ({ url }) => { 83 | let results: SearchResultsType | null 84 | 85 | // Use Jina if the API key is set, otherwise use Tavily 86 | const useJina = process.env.JINA_API_KEY 87 | if (useJina) { 88 | results = await fetchJinaReaderData(url) 89 | } else { 90 | results = await fetchTavilyExtractData(url) 91 | } 92 | 93 | if (!results) { 94 | return null 95 | } 96 | 97 | return results 98 | } 99 | }) 100 | -------------------------------------------------------------------------------- /lib/tools/search/providers/base.ts: -------------------------------------------------------------------------------- 1 | import { SearchResults } from '@/lib/types' 2 | 3 | export interface SearchProvider { 4 | search( 5 | query: string, 6 | maxResults: number, 7 | searchDepth: 'basic' | 'advanced', 8 | includeDomains: string[], 9 | excludeDomains: string[] 10 | ): Promise<SearchResults> 11 | } 12 | 13 | export abstract class BaseSearchProvider implements SearchProvider { 14 | abstract search( 15 | query: string, 16 | maxResults: number, 17 | searchDepth: 'basic' | 'advanced', 18 | includeDomains: string[], 19 | excludeDomains: string[] 20 | ): Promise<SearchResults> 21 | 22 | protected validateApiKey(key: string | undefined, providerName: string): void { 23 | if (!key) { 24 | throw new Error(`${providerName}_API_KEY is not set in the environment variables`) 25 | } 26 | } 27 | 28 | protected validateApiUrl(url: string | undefined, providerName: string): void { 29 | if (!url) { 30 | throw new Error(`${providerName}_API_URL is not set in the environment variables`) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /lib/tools/search/providers/exa.ts: -------------------------------------------------------------------------------- 1 | import { SearchResults } from '@/lib/types' 2 | import Exa from 'exa-js' 3 | import { BaseSearchProvider } from './base' 4 | 5 | export class ExaSearchProvider extends BaseSearchProvider { 6 | async search( 7 | query: string, 8 | maxResults: number = 10, 9 | _searchDepth: 'basic' | 'advanced' = 'basic', 10 | includeDomains: string[] = [], 11 | excludeDomains: string[] = [] 12 | ): Promise<SearchResults> { 13 | const apiKey = process.env.EXA_API_KEY 14 | this.validateApiKey(apiKey, 'EXA') 15 | 16 | const exa = new Exa(apiKey) 17 | const exaResults = await exa.searchAndContents(query, { 18 | highlights: true, 19 | numResults: maxResults, 20 | includeDomains, 21 | excludeDomains 22 | }) 23 | 24 | return { 25 | results: exaResults.results.map((result: any) => ({ 26 | title: result.title, 27 | url: result.url, 28 | content: result.highlight || result.text 29 | })), 30 | query, 31 | images: [], 32 | number_of_results: exaResults.results.length 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /lib/tools/search/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { SearchProvider } from './base' 2 | import { ExaSearchProvider } from './exa' 3 | import { SearXNGSearchProvider } from './searxng' 4 | import { TavilySearchProvider } from './tavily' 5 | 6 | export type SearchProviderType = 'tavily' | 'exa' | 'searxng' 7 | export const DEFAULT_PROVIDER: SearchProviderType = 'tavily' 8 | 9 | export function createSearchProvider(type?: SearchProviderType): SearchProvider { 10 | const providerType = type || (process.env.SEARCH_API as SearchProviderType) || DEFAULT_PROVIDER 11 | 12 | switch (providerType) { 13 | case 'tavily': 14 | return new TavilySearchProvider() 15 | case 'exa': 16 | return new ExaSearchProvider() 17 | case 'searxng': 18 | return new SearXNGSearchProvider() 19 | default: 20 | // Default to TavilySearchProvider if an unknown provider is specified 21 | return new TavilySearchProvider() 22 | } 23 | } 24 | 25 | export type { ExaSearchProvider } from './exa' 26 | export { SearXNGSearchProvider } from './searxng' 27 | export { TavilySearchProvider } from './tavily' 28 | export type { SearchProvider } 29 | 30 | -------------------------------------------------------------------------------- /lib/tools/search/providers/searxng.ts: -------------------------------------------------------------------------------- 1 | import { SearchResultItem, SearchResults, SearXNGResponse, SearXNGResult } from '@/lib/types' 2 | import { BaseSearchProvider } from './base' 3 | 4 | export class SearXNGSearchProvider extends BaseSearchProvider { 5 | async search( 6 | query: string, 7 | maxResults: number = 10, 8 | searchDepth: 'basic' | 'advanced' = 'basic', 9 | includeDomains: string[] = [], 10 | excludeDomains: string[] = [] 11 | ): Promise<SearchResults> { 12 | const apiUrl = process.env.SEARXNG_API_URL 13 | this.validateApiUrl(apiUrl, 'SEARXNG') 14 | 15 | try { 16 | // Construct the URL with query parameters 17 | const url = new URL(`${apiUrl}/search`) 18 | url.searchParams.append('q', query) 19 | url.searchParams.append('format', 'json') 20 | url.searchParams.append('categories', 'general,images') 21 | 22 | // Apply search depth settings 23 | if (searchDepth === 'advanced') { 24 | url.searchParams.append('time_range', '') 25 | url.searchParams.append('safesearch', '0') 26 | url.searchParams.append('engines', 'google,bing,duckduckgo,wikipedia') 27 | } else { 28 | url.searchParams.append('time_range', 'year') 29 | url.searchParams.append('safesearch', '1') 30 | url.searchParams.append('engines', 'google,bing') 31 | } 32 | 33 | // Apply domain filters if provided 34 | if (includeDomains.length > 0) { 35 | url.searchParams.append('site', includeDomains.join(',')) 36 | } 37 | 38 | // Fetch results from SearXNG 39 | const response = await fetch(url.toString(), { 40 | method: 'GET', 41 | headers: { 42 | Accept: 'application/json' 43 | } 44 | }) 45 | 46 | if (!response.ok) { 47 | const errorText = await response.text() 48 | console.error(`SearXNG API error (${response.status}):`, errorText) 49 | throw new Error( 50 | `SearXNG API error: ${response.status} ${response.statusText} - ${errorText}` 51 | ) 52 | } 53 | 54 | const data: SearXNGResponse = await response.json() 55 | 56 | // Separate general results and image results, and limit to maxResults 57 | const generalResults = data.results 58 | .filter(result => !result.img_src) 59 | .slice(0, maxResults) 60 | const imageResults = data.results 61 | .filter(result => result.img_src) 62 | .slice(0, maxResults) 63 | 64 | // Format the results to match the expected SearchResults structure 65 | return { 66 | results: generalResults.map( 67 | (result: SearXNGResult): SearchResultItem => ({ 68 | title: result.title, 69 | url: result.url, 70 | content: result.content 71 | }) 72 | ), 73 | query: data.query, 74 | images: imageResults 75 | .map(result => { 76 | const imgSrc = result.img_src || '' 77 | return imgSrc.startsWith('http') ? imgSrc : `${apiUrl}${imgSrc}` 78 | }) 79 | .filter(Boolean), 80 | number_of_results: data.number_of_results 81 | } 82 | } catch (error) { 83 | console.error('SearXNG API error:', error) 84 | throw error 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /lib/tools/search/providers/tavily.ts: -------------------------------------------------------------------------------- 1 | import { SearchResultImage, SearchResults } from '@/lib/types' 2 | import { sanitizeUrl } from '@/lib/utils' 3 | import { BaseSearchProvider } from './base' 4 | 5 | export class TavilySearchProvider extends BaseSearchProvider { 6 | async search( 7 | query: string, 8 | maxResults: number = 10, 9 | searchDepth: 'basic' | 'advanced' = 'basic', 10 | includeDomains: string[] = [], 11 | excludeDomains: string[] = [] 12 | ): Promise<SearchResults> { 13 | const apiKey = process.env.TAVILY_API_KEY 14 | this.validateApiKey(apiKey, 'TAVILY') 15 | 16 | // Tavily API requires a minimum of 5 characters in the query 17 | const filledQuery = 18 | query.length < 5 ? query + ' '.repeat(5 - query.length) : query 19 | 20 | const includeImageDescriptions = true 21 | const response = await fetch('https://api.tavily.com/search', { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/json' 25 | }, 26 | body: JSON.stringify({ 27 | api_key: apiKey, 28 | query: filledQuery, 29 | max_results: Math.max(maxResults, 5), 30 | search_depth: searchDepth, 31 | include_images: true, 32 | include_image_descriptions: includeImageDescriptions, 33 | include_answers: true, 34 | include_domains: includeDomains, 35 | exclude_domains: excludeDomains 36 | }) 37 | }) 38 | 39 | if (!response.ok) { 40 | throw new Error( 41 | `Tavily API error: ${response.status} ${response.statusText}` 42 | ) 43 | } 44 | 45 | const data = await response.json() 46 | const processedImages = includeImageDescriptions 47 | ? data.images 48 | .map(({ url, description }: { url: string; description: string }) => ({ 49 | url: sanitizeUrl(url), 50 | description 51 | })) 52 | .filter( 53 | ( 54 | image: SearchResultImage 55 | ): image is { url: string; description: string } => 56 | typeof image === 'object' && 57 | image.description !== undefined && 58 | image.description !== '' 59 | ) 60 | : data.images.map((url: string) => sanitizeUrl(url)) 61 | 62 | return { 63 | ...data, 64 | images: processedImages 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /lib/tools/video-search.ts: -------------------------------------------------------------------------------- 1 | import { getSearchSchemaForModel } from '@/lib/schema/search' 2 | import { tool } from 'ai' 3 | 4 | /** 5 | * Creates a video search tool with the appropriate schema for the model. 6 | */ 7 | export function createVideoSearchTool(fullModel: string) { 8 | return tool({ 9 | description: 'Search for videos from YouTube', 10 | parameters: getSearchSchemaForModel(fullModel), 11 | execute: async ({ query }) => { 12 | try { 13 | const response = await fetch('https://google.serper.dev/videos', { 14 | method: 'POST', 15 | headers: { 16 | 'X-API-KEY': process.env.SERPER_API_KEY || '', 17 | 'Content-Type': 'application/json' 18 | }, 19 | body: JSON.stringify({ q: query }) 20 | }) 21 | 22 | if (!response.ok) { 23 | throw new Error('Network response was not ok') 24 | } 25 | 26 | return await response.json() 27 | } catch (error) { 28 | console.error('Video Search API error:', error) 29 | return null 30 | } 31 | } 32 | }) 33 | } 34 | 35 | // Default export for backward compatibility, using a default model 36 | export const videoSearchTool = createVideoSearchTool('openai:gpt-4o-mini') 37 | -------------------------------------------------------------------------------- /lib/types/index.ts: -------------------------------------------------------------------------------- 1 | import { CoreMessage, JSONValue, Message } from 'ai' 2 | 3 | export type SearchResults = { 4 | images: SearchResultImage[] 5 | results: SearchResultItem[] 6 | number_of_results?: number 7 | query: string 8 | } 9 | 10 | // If enabled the include_images_description is true, the images will be an array of { url: string, description: string } 11 | // Otherwise, the images will be an array of strings 12 | export type SearchResultImage = 13 | | string 14 | | { 15 | url: string 16 | description: string 17 | number_of_results?: number 18 | } 19 | 20 | export type ExaSearchResults = { 21 | results: ExaSearchResultItem[] 22 | } 23 | 24 | export type SerperSearchResults = { 25 | searchParameters: { 26 | q: string 27 | type: string 28 | engine: string 29 | } 30 | videos: SerperSearchResultItem[] 31 | } 32 | 33 | export type SearchResultItem = { 34 | title: string 35 | url: string 36 | content: string 37 | } 38 | 39 | export type ExaSearchResultItem = { 40 | score: number 41 | title: string 42 | id: string 43 | url: string 44 | publishedDate: Date 45 | author: string 46 | } 47 | 48 | export type SerperSearchResultItem = { 49 | title: string 50 | link: string 51 | snippet: string 52 | imageUrl: string 53 | duration: string 54 | source: string 55 | channel: string 56 | date: string 57 | position: number 58 | } 59 | 60 | export interface Chat extends Record<string, any> { 61 | id: string 62 | title: string 63 | createdAt: Date 64 | userId: string 65 | path: string 66 | messages: ExtendedCoreMessage[] // Note: Changed from AIMessage to ExtendedCoreMessage 67 | sharePath?: string 68 | } 69 | 70 | // ExtendedCoreMessage for saveing annotations 71 | export type ExtendedCoreMessage = Omit<CoreMessage, 'role' | 'content'> & { 72 | role: CoreMessage['role'] | 'data' 73 | content: CoreMessage['content'] | JSONValue 74 | } 75 | 76 | export type AIMessage = { 77 | role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool' 78 | content: string 79 | id: string 80 | name?: string 81 | type?: 82 | | 'answer' 83 | | 'related' 84 | | 'skip' 85 | | 'inquiry' 86 | | 'input' 87 | | 'input_related' 88 | | 'tool' 89 | | 'followup' 90 | | 'end' 91 | } 92 | 93 | export interface SearXNGResult { 94 | title: string 95 | url: string 96 | content: string 97 | img_src?: string 98 | publishedDate?: string 99 | score?: number 100 | } 101 | 102 | export interface SearXNGResponse { 103 | query: string 104 | number_of_results: number 105 | results: SearXNGResult[] 106 | } 107 | 108 | export type SearXNGImageResult = string 109 | 110 | export type SearXNGSearchResults = { 111 | images: SearXNGImageResult[] 112 | results: SearchResultItem[] 113 | number_of_results?: number 114 | query: string 115 | } 116 | -------------------------------------------------------------------------------- /lib/types/models.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | id: string 3 | name: string 4 | provider: string 5 | providerId: string 6 | enabled: boolean 7 | toolCallType: 'native' | 'manual' 8 | toolCallModel?: string 9 | } 10 | -------------------------------------------------------------------------------- /lib/utils/context-window.ts: -------------------------------------------------------------------------------- 1 | import { CoreMessage } from 'ai' 2 | import { Model } from '../types/models' 3 | 4 | const DEFAULT_CONTEXT_WINDOW = 128_000 5 | const DEFAULT_RESERVE_TOKENS = 30_000 6 | 7 | export function getMaxAllowedTokens(model: Model): number { 8 | let contextWindow: number 9 | let reserveTokens: number 10 | 11 | if (model.id.includes('deepseek')) { 12 | contextWindow = 64_000 13 | reserveTokens = 27_000 14 | } else if (model.id.includes('claude')) { 15 | contextWindow = 200_000 16 | reserveTokens = 40_000 17 | } else { 18 | contextWindow = DEFAULT_CONTEXT_WINDOW 19 | reserveTokens = DEFAULT_RESERVE_TOKENS 20 | } 21 | 22 | return contextWindow - reserveTokens 23 | } 24 | 25 | export function truncateMessages( 26 | messages: CoreMessage[], 27 | maxTokens: number 28 | ): CoreMessage[] { 29 | let totalTokens = 0 30 | const tempMessages: CoreMessage[] = [] 31 | 32 | for (let i = messages.length - 1; i >= 0; i--) { 33 | const message = messages[i] 34 | const messageTokens = message.content?.length || 0 35 | 36 | if (totalTokens + messageTokens <= maxTokens) { 37 | tempMessages.push(message) 38 | totalTokens += messageTokens 39 | } else { 40 | break 41 | } 42 | } 43 | 44 | const orderedMessages = tempMessages.reverse() 45 | 46 | while (orderedMessages.length > 0 && orderedMessages[0].role !== 'user') { 47 | orderedMessages.shift() 48 | } 49 | 50 | return orderedMessages 51 | } 52 | -------------------------------------------------------------------------------- /lib/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | export function setCookie(name: string, value: string, days = 30) { 2 | const date = new Date() 3 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000) 4 | const expires = `expires=${date.toUTCString()}` 5 | document.cookie = `${name}=${value};${expires};path=/` 6 | } 7 | 8 | export function getCookie(name: string): string | null { 9 | const cookies = document.cookie.split(';') 10 | for (const cookie of cookies) { 11 | const [cookieName, cookieValue] = cookie.trim().split('=') 12 | if (cookieName === name) { 13 | return cookieValue 14 | } 15 | } 16 | return null 17 | } 18 | 19 | export function deleteCookie(name: string) { 20 | document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/` 21 | } 22 | -------------------------------------------------------------------------------- /lib/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers' 2 | 3 | /** 4 | * Helper function to get base URL from headers 5 | * Extracts URL information from Next.js request headers 6 | */ 7 | export async function getBaseUrlFromHeaders(): Promise<URL> { 8 | const headersList = await headers() 9 | const baseUrl = headersList.get('x-base-url') 10 | const url = headersList.get('x-url') 11 | const host = headersList.get('x-host') 12 | const protocol = headersList.get('x-protocol') || 'http:' 13 | 14 | try { 15 | // Try to use the pre-constructed base URL if available 16 | if (baseUrl) { 17 | return new URL(baseUrl) 18 | } else if (url) { 19 | return new URL(url) 20 | } else if (host) { 21 | const constructedUrl = `${protocol}${ 22 | protocol.endsWith(':') ? '//' : '://' 23 | }${host}` 24 | return new URL(constructedUrl) 25 | } else { 26 | return new URL('http://localhost:3000') 27 | } 28 | } catch (urlError) { 29 | // Fallback to default URL if any error occurs during URL construction 30 | return new URL('http://localhost:3000') 31 | } 32 | } 33 | 34 | /** 35 | * Resolves the base URL using environment variables or headers 36 | * Centralizes the base URL resolution logic used across the application 37 | * @returns A URL object representing the base URL 38 | */ 39 | export async function getBaseUrl(): Promise<URL> { 40 | // Check for environment variables first 41 | const baseUrlEnv = process.env.NEXT_PUBLIC_BASE_URL || process.env.BASE_URL 42 | 43 | if (baseUrlEnv) { 44 | try { 45 | const baseUrlObj = new URL(baseUrlEnv) 46 | console.log('Using BASE_URL environment variable:', baseUrlEnv) 47 | return baseUrlObj 48 | } catch (error) { 49 | console.warn( 50 | 'Invalid BASE_URL environment variable, falling back to headers' 51 | ) 52 | // Fall back to headers if the environment variable is invalid 53 | } 54 | } 55 | 56 | // If no valid environment variable is available, use headers 57 | return await getBaseUrlFromHeaders() 58 | } 59 | 60 | /** 61 | * Gets the base URL as a string 62 | * Convenience wrapper around getBaseUrl that returns a string 63 | * @returns A string representation of the base URL 64 | */ 65 | export async function getBaseUrlString(): Promise<string> { 66 | const baseUrlObj = await getBaseUrl() 67 | return baseUrlObj.toString() 68 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { updateSession } from '@/lib/supabase/middleware' 2 | import { type NextRequest, NextResponse } from 'next/server' 3 | 4 | export async function middleware(request: NextRequest) { 5 | // Get the protocol from X-Forwarded-Proto header or request protocol 6 | const protocol = 7 | request.headers.get('x-forwarded-proto') || request.nextUrl.protocol 8 | 9 | // Get the host from X-Forwarded-Host header or request host 10 | const host = 11 | request.headers.get('x-forwarded-host') || request.headers.get('host') || '' 12 | 13 | // Construct the base URL - ensure protocol has :// format 14 | const baseUrl = `${protocol}${protocol.endsWith(':') ? '//' : '://'}${host}` 15 | 16 | // Create a response 17 | let response: NextResponse 18 | 19 | // Handle Supabase session if configured 20 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL 21 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY 22 | 23 | if (supabaseUrl && supabaseAnonKey) { 24 | response = await updateSession(request) 25 | } else { 26 | // If Supabase is not configured, just pass the request through 27 | response = NextResponse.next({ 28 | request 29 | }) 30 | } 31 | 32 | // Add request information to response headers 33 | response.headers.set('x-url', request.url) 34 | response.headers.set('x-host', host) 35 | response.headers.set('x-protocol', protocol) 36 | response.headers.set('x-base-url', baseUrl) 37 | 38 | return response 39 | } 40 | 41 | export const config = { 42 | matcher: [ 43 | /* 44 | * Match all request paths except for the ones starting with: 45 | * - _next/static (static files) 46 | * - _next/image (image optimization files) 47 | * - favicon.ico (favicon file) 48 | * Feel free to modify this pattern to include more paths. 49 | */ 50 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)' 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'i.ytimg.com', 8 | port: '', 9 | pathname: '/vi/**' 10 | }, 11 | { 12 | protocol: 'https', 13 | hostname: 'lh3.googleusercontent.com', 14 | port: '', 15 | pathname: '/a/**' // Google user content often follows this pattern 16 | } 17 | ] 18 | } 19 | } 20 | 21 | export default nextConfig 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "morphic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "dev": "next dev --turbo", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@ai-sdk/anthropic": "^1.2.10", 14 | "@ai-sdk/azure": "^1.1.5", 15 | "@ai-sdk/deepseek": "^0.1.6", 16 | "@ai-sdk/fireworks": "^0.1.6", 17 | "@ai-sdk/google": "^1.1.5", 18 | "@ai-sdk/groq": "^1.1.6", 19 | "@ai-sdk/openai": "^1.3.12", 20 | "@ai-sdk/xai": "^1.1.10", 21 | "@radix-ui/react-alert-dialog": "^1.0.5", 22 | "@radix-ui/react-avatar": "^1.1.9", 23 | "@radix-ui/react-checkbox": "^1.0.4", 24 | "@radix-ui/react-collapsible": "^1.0.3", 25 | "@radix-ui/react-dialog": "^1.1.11", 26 | "@radix-ui/react-dropdown-menu": "^2.0.6", 27 | "@radix-ui/react-label": "^2.1.6", 28 | "@radix-ui/react-popover": "^1.1.5", 29 | "@radix-ui/react-select": "^2.1.2", 30 | "@radix-ui/react-separator": "^1.1.4", 31 | "@radix-ui/react-slider": "^1.1.2", 32 | "@radix-ui/react-slot": "^1.2.2", 33 | "@radix-ui/react-switch": "^1.0.3", 34 | "@radix-ui/react-toggle": "^1.1.1", 35 | "@radix-ui/react-tooltip": "^1.2.4", 36 | "@supabase/auth-helpers-nextjs": "^0.10.0", 37 | "@supabase/auth-ui-react": "^0.4.7", 38 | "@supabase/auth-ui-shared": "^0.1.8", 39 | "@supabase/ssr": "^0.6.1", 40 | "@supabase/supabase-js": "^2.49.4", 41 | "@tailwindcss/typography": "^0.5.12", 42 | "@upstash/redis": "^1.34.0", 43 | "@vercel/analytics": "^1.5.0", 44 | "ai": "^4.3.6", 45 | "class-variance-authority": "^0.7.1", 46 | "clsx": "^2.1.0", 47 | "cmdk": "1.0.0", 48 | "embla-carousel-react": "^8.0.0", 49 | "exa-js": "^1.0.12", 50 | "jsdom": "^22.1.0", 51 | "katex": "^0.16.10", 52 | "lucide-react": "^0.507.0", 53 | "next": "^15.2.3", 54 | "next-themes": "^0.3.0", 55 | "node-html-parser": "^6.1.13", 56 | "ollama-ai-provider": "^1.2.0", 57 | "react": "^19.0.0", 58 | "react-dom": "^19.0.0", 59 | "react-icons": "^5.0.1", 60 | "react-markdown": "^8.0.7", 61 | "react-resizable-panels": "^3.0.0", 62 | "react-syntax-highlighter": "^15.5.0", 63 | "react-textarea-autosize": "^8.5.3", 64 | "redis": "^4.7.0", 65 | "rehype-external-links": "^3.0.0", 66 | "rehype-katex": "^6.0.0", 67 | "remark-gfm": "^3.0.1", 68 | "remark-math": "^5.1.1", 69 | "sonner": "^1.4.41", 70 | "tailwind-merge": "^2.6.0", 71 | "tailwindcss-animate": "^1.0.7", 72 | "vaul": "^1.1.2", 73 | "zod": "^3.23.8" 74 | }, 75 | "devDependencies": { 76 | "@types/jsdom": "^21.1.7", 77 | "@types/node": "^20", 78 | "@types/react": "^18", 79 | "@types/react-dom": "^18", 80 | "@types/react-syntax-highlighter": "^15.5.13", 81 | "eslint": "^8", 82 | "eslint-config-next": "^14.2.25", 83 | "postcss": "^8", 84 | "tailwindcss": "^3.4.1", 85 | "typescript": "^5" 86 | }, 87 | "engines": { 88 | "bun": "1.2.12" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: 'lf', 4 | semi: false, 5 | useTabs: false, 6 | singleQuote: true, 7 | arrowParens: 'avoid', 8 | tabWidth: 2, 9 | trailingComma: 'none', 10 | importOrder: [ 11 | '^(react/(.*)$)|^(react$)', 12 | '^(next/(.*)$)|^(next$)', 13 | '<THIRD_PARTY_MODULES>', 14 | '', 15 | '^types$', 16 | '^@/types/(.*)$', 17 | '^@/config/(.*)$', 18 | '^@/lib/(.*)$', 19 | '^@/hooks/(.*)$', 20 | '^@/components/ui/(.*)$', 21 | '^@/components/(.*)$', 22 | '^@/registry/(.*)$', 23 | '^@/styles/(.*)$', 24 | '^@/app/(.*)$', 25 | '', 26 | '^[./]', 27 | ], 28 | importOrderSeparation: false, 29 | importOrderSortSpecifiers: true, 30 | importOrderBuiltinModulesToTop: true, 31 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 32 | importOrderMergeDuplicateImports: true, 33 | importOrderCombineTypeAndValueImports: true, 34 | }; 35 | -------------------------------------------------------------------------------- /public/images/placeholder-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miurla/morphic/4800cae1f6882c2dd95a050536cfba59fee05b0a/public/images/placeholder-image.png -------------------------------------------------------------------------------- /public/providers/logos/anthropic.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M54.8717 28L72.392 71.945H82L64.4796 28H54.8717Z" fill="#181818"/> 3 | <path d="M34.5457 54.5553L40.5406 39.1118L46.5355 54.5553H34.5457ZM35.5176 28L18 71.945H27.7948L31.3774 62.7165H49.7044L53.2864 71.945H63.0812L45.5636 28H35.5176Z" fill="#181818"/> 4 | </svg> 5 | -------------------------------------------------------------------------------- /public/providers/logos/azure.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M39.3358 20.0007H58.274L38.6143 78.2504C38.4123 78.8489 38.0276 79.369 37.5144 79.7375C37.0013 80.106 36.3855 80.3042 35.7537 80.3043H21.0151C20.5365 80.3044 20.0647 80.1905 19.6388 79.972C19.2129 79.7536 18.8452 79.4368 18.566 79.048C18.2868 78.6592 18.1042 78.2096 18.0333 77.7362C17.9624 77.2628 18.0052 76.7794 18.1582 76.3258L36.4745 22.0547C36.6765 21.4559 37.0612 20.9355 37.5745 20.5669C38.0879 20.1983 38.7039 20.0008 39.3358 20.0007Z" fill="url(#paint0_linear_279_51)"/> 3 | <path d="M66.8558 59.0708H36.8243C36.5451 59.0705 36.2723 59.1543 36.0414 59.3112C35.8105 59.4682 35.6322 59.6911 35.5298 59.9508C35.4274 60.2105 35.4055 60.4951 35.4671 60.7674C35.5287 61.0397 35.6709 61.2872 35.8752 61.4775L55.1727 79.4891C55.7345 80.0132 56.4743 80.3046 57.2426 80.3045H74.2476L66.8558 59.0708Z" fill="#0078D4"/> 4 | <path d="M39.3356 20.0005C38.6968 19.9981 38.0739 20.1999 37.5579 20.5767C37.042 20.9534 36.66 21.4852 36.4678 22.0945L18.1805 76.2762C18.0172 76.7314 17.966 77.2192 18.0311 77.6984C18.0962 78.1775 18.2757 78.634 18.5546 79.0291C18.8334 79.4242 19.2033 79.7464 19.6329 79.9683C20.0626 80.1902 20.5394 80.3054 21.0229 80.3041H36.1419C36.705 80.2035 37.2313 79.9552 37.6671 79.5847C38.1028 79.2141 38.4324 78.7345 38.6221 78.1949L42.269 67.4471L55.2954 79.5972C55.8413 80.0487 56.5261 80.2983 57.2345 80.3041H74.1762L66.7458 59.0705L45.0852 59.0756L58.3422 20.0005H39.3356Z" fill="url(#paint1_linear_279_51)"/> 5 | <path d="M63.5245 22.0518C63.3228 21.454 62.9386 20.9345 62.4261 20.5665C61.9135 20.1985 61.2985 20.0007 60.6675 20.0007H39.5612C40.1921 20.0008 40.8071 20.1987 41.3196 20.5666C41.8321 20.9346 42.2163 21.454 42.4181 22.0518L60.7352 76.3251C60.8883 76.7787 60.9312 77.2623 60.8604 77.7358C60.7895 78.2093 60.6069 78.6591 60.3277 79.048C60.0486 79.437 59.6808 79.7538 59.2548 79.9724C58.8289 80.191 58.357 80.305 57.8782 80.3051H78.9853C79.464 80.3049 79.9358 80.1908 80.3617 79.9722C80.7875 79.7535 81.1552 79.4367 81.4343 79.0477C81.7134 78.6588 81.8959 78.2091 81.9668 77.7356C82.0376 77.2622 81.9946 76.7787 81.8415 76.3251L63.5245 22.0518Z" fill="url(#paint2_linear_279_51)"/> 6 | <defs> 7 | <linearGradient id="paint0_linear_279_51" x1="46.2382" y1="24.4694" x2="26.5705" y2="82.5729" gradientUnits="userSpaceOnUse"> 8 | <stop stop-color="#114A8B"/> 9 | <stop offset="1" stop-color="#0669BC"/> 10 | </linearGradient> 11 | <linearGradient id="paint1_linear_279_51" x1="52.3817" y1="51.547" x2="47.8323" y2="53.0853" gradientUnits="userSpaceOnUse"> 12 | <stop stop-opacity="0.3"/> 13 | <stop offset="0.071" stop-opacity="0.2"/> 14 | <stop offset="0.321" stop-opacity="0.1"/> 15 | <stop offset="0.623" stop-opacity="0.05"/> 16 | <stop offset="1" stop-opacity="0"/> 17 | </linearGradient> 18 | <linearGradient id="paint2_linear_279_51" x1="49.8798" y1="22.7748" x2="71.4691" y2="80.2927" gradientUnits="userSpaceOnUse"> 19 | <stop stop-color="#3CCBF4"/> 20 | <stop offset="1" stop-color="#2892DF"/> 21 | </linearGradient> 22 | </defs> 23 | </svg> 24 | -------------------------------------------------------------------------------- /public/providers/logos/fireworks.svg: -------------------------------------------------------------------------------- 1 | <svg width="162" height="162" viewBox="0 0 162 162" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M97.9839 46L81.3322 85.9032L64.6648 46H53.9724L72.2409 89.621C73.7561 93.2606 77.3098 95.6115 81.2697 95.6115C85.2296 95.6115 88.7755 93.2606 90.2985 89.6366L108.676 46H97.9839ZM105.099 106.796L135.56 75.9997L131.405 66.1976L98.1323 99.8994C95.3518 102.719 94.5552 106.874 96.0938 110.514C97.6246 114.122 101.163 116.457 105.107 116.457L105.123 116.473L152.68 116.356L148.525 106.554L105.107 106.796H105.099ZM27.1204 75.9762L31.2755 66.1742L64.5477 99.876C67.3282 102.688 68.1326 106.858 66.5862 110.49C65.0554 114.107 61.5016 116.434 57.573 116.434L10.0156 116.325L10 116.34L14.1551 106.538L57.573 106.78L27.1204 75.9762Z" fill="black"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/providers/logos/google.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <g clip-path="url(#clip0_278_14)"> 3 | <mask id="mask0_278_14" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="19" y="18" width="63" height="64"> 4 | <path d="M80.8182 44.1818H51V56.5455H68.1637C66.5637 64.4 59.8727 68.9091 51 68.9091C40.5273 68.9091 32.0909 60.4727 32.0909 50C32.0909 39.5273 40.5273 31.0909 51 31.0909C55.5091 31.0909 59.5818 32.6909 62.7818 35.3091L72.0909 26C66.4182 21.0545 59.1455 18 51 18C33.2546 18 19 32.2546 19 50C19 67.7455 33.2546 82 51 82C67 82 81.5455 70.3637 81.5455 50C81.5455 48.1091 81.2546 46.0727 80.8182 44.1818Z" fill="white"/> 5 | </mask> 6 | <g mask="url(#mask0_278_14)"> 7 | <path d="M16.0909 68.909V31.0908L40.8182 49.9999L16.0909 68.909Z" fill="#FBBC05"/> 8 | </g> 9 | <mask id="mask1_278_14" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="19" y="18" width="63" height="64"> 10 | <path d="M80.8182 44.1818H51V56.5455H68.1637C66.5637 64.4 59.8727 68.9091 51 68.9091C40.5273 68.9091 32.0909 60.4727 32.0909 50C32.0909 39.5273 40.5273 31.0909 51 31.0909C55.5091 31.0909 59.5818 32.6909 62.7818 35.3091L72.0909 26C66.4182 21.0545 59.1455 18 51 18C33.2546 18 19 32.2546 19 50C19 67.7455 33.2546 82 51 82C67 82 81.5455 70.3637 81.5455 50C81.5455 48.1091 81.2546 46.0727 80.8182 44.1818Z" fill="white"/> 11 | </mask> 12 | <g mask="url(#mask1_278_14)"> 13 | <path d="M16.0909 31.0908L40.8182 49.9999L51 41.1272L85.9091 35.4545V15.0908H16.0909V31.0908Z" fill="#EA4335"/> 14 | </g> 15 | <mask id="mask2_278_14" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="19" y="18" width="63" height="64"> 16 | <path d="M80.8182 44.1818H51V56.5455H68.1637C66.5637 64.4 59.8727 68.9091 51 68.9091C40.5273 68.9091 32.0909 60.4727 32.0909 50C32.0909 39.5273 40.5273 31.0909 51 31.0909C55.5091 31.0909 59.5818 32.6909 62.7818 35.3091L72.0909 26C66.4182 21.0545 59.1455 18 51 18C33.2546 18 19 32.2546 19 50C19 67.7455 33.2546 82 51 82C67 82 81.5455 70.3637 81.5455 50C81.5455 48.1091 81.2546 46.0727 80.8182 44.1818Z" fill="white"/> 17 | </mask> 18 | <g mask="url(#mask2_278_14)"> 19 | <path d="M16.0909 68.909L59.7273 35.4545L71.2182 36.909L85.9091 15.0908V84.909H16.0909V68.909Z" fill="#34A853"/> 20 | </g> 21 | <mask id="mask3_278_14" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="19" y="18" width="63" height="64"> 22 | <path d="M80.8182 44.1818H51V56.5455H68.1637C66.5637 64.4 59.8727 68.9091 51 68.9091C40.5273 68.9091 32.0909 60.4727 32.0909 50C32.0909 39.5273 40.5273 31.0909 51 31.0909C55.5091 31.0909 59.5818 32.6909 62.7818 35.3091L72.0909 26C66.4182 21.0545 59.1455 18 51 18C33.2546 18 19 32.2546 19 50C19 67.7455 33.2546 82 51 82C67 82 81.5455 70.3637 81.5455 50C81.5455 48.1091 81.2546 46.0727 80.8182 44.1818Z" fill="white"/> 23 | </mask> 24 | <g mask="url(#mask3_278_14)"> 25 | <path d="M85.9091 84.909L40.8182 49.9999L35 45.6363L85.9091 31.0908V84.909Z" fill="#4285F4"/> 26 | </g> 27 | </g> 28 | <defs> 29 | <clipPath id="clip0_278_14"> 30 | <rect width="64" height="64" fill="white" transform="translate(18 18)"/> 31 | </clipPath> 32 | </defs> 33 | </svg> 34 | -------------------------------------------------------------------------------- /public/providers/logos/groq.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <rect width="100" height="100" fill="#F55036"/> 3 | <g clip-path="url(#clip0_4_12)"> 4 | <path d="M50.0502 10.0009C34.9224 9.87911 22.5475 22.0105 22.4013 37.1382C22.2551 52.266 34.411 64.641 49.5386 64.7872C49.7092 64.7872 49.8796 64.7872 50.0502 64.7872H59.0636V54.5314H50.0502C40.5984 54.6532 32.8518 47.0772 32.73 37.6254C32.6082 28.1737 40.1842 20.4271 49.636 20.3053C49.7578 20.3053 49.904 20.3053 50.0258 20.3053C59.4532 20.3053 67.1754 27.9788 67.1754 37.4306V62.6434C67.1754 72.0222 59.5508 79.6468 50.1964 79.7686C45.714 79.72 41.451 77.9416 38.2842 74.7504L31.0248 81.9854C36.0674 87.0524 42.8882 89.927 50.0258 90H50.3912C65.324 89.7808 77.3336 77.6738 77.4068 62.7408V36.7242C77.0414 21.8644 64.8856 10.0253 50.0502 10.0009Z" fill="white"/> 5 | </g> 6 | <defs> 7 | <clipPath id="clip0_4_12"> 8 | <rect width="80" height="80" fill="white" transform="translate(10 10)"/> 9 | </clipPath> 10 | </defs> 11 | </svg> 12 | -------------------------------------------------------------------------------- /public/providers/logos/openai-compatible.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <circle cx="50" cy="50" r="32" fill="black"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/providers/logos/openai.svg: -------------------------------------------------------------------------------- 1 | <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <g clip-path="url(#clip0_278_47)"> 3 | <path d="M77.4119 44.1941C78.8639 39.8361 78.3639 35.0621 76.0419 31.0981C72.5499 25.0181 65.5299 21.8901 58.6739 23.3621C55.6239 19.9261 51.2419 17.9721 46.6479 18.0001C39.6399 17.9841 33.4219 22.4961 31.2659 29.1641C26.7639 30.0861 22.8779 32.9041 20.6039 36.8981C17.0859 42.9621 17.8879 50.6061 22.5879 55.8061C21.1359 60.1641 21.6359 64.9381 23.9579 68.9021C27.4499 74.9821 34.4699 78.1101 41.3259 76.6381C44.3739 80.0741 48.7579 82.0281 53.3519 81.9981C60.3639 82.0161 66.5839 77.5001 68.7399 70.8261C73.2419 69.9041 77.1279 67.0861 79.4019 63.0921C82.9159 57.0281 82.1119 49.3901 77.4139 44.1901L77.4119 44.1941ZM53.3559 77.8161C50.5499 77.8201 47.8319 76.8381 45.6779 75.0401C45.7759 74.9881 45.9459 74.8941 46.0559 74.8261L58.7999 67.4661C59.4519 67.0961 59.8519 66.4021 59.8479 65.6521V47.6861L65.2339 50.7961C65.2919 50.8241 65.3299 50.8801 65.3379 50.9441V65.8221C65.3299 72.4381 59.9719 77.8021 53.3559 77.8161ZM27.5879 66.8101C26.1819 64.3821 25.6759 61.5361 26.1579 58.7741C26.2519 58.8301 26.4179 58.9321 26.5359 59.0001L39.2799 66.3601C39.9259 66.7381 40.7259 66.7381 41.3739 66.3601L56.9319 57.3761V63.5961C56.9359 63.6601 56.9059 63.7221 56.8559 63.7621L43.9739 71.2001C38.2359 74.5041 30.9079 72.5401 27.5899 66.8101H27.5879ZM24.2339 38.9921C25.6339 36.5601 27.8439 34.7001 30.4759 33.7341C30.4759 33.8441 30.4699 34.0381 30.4699 34.1741V48.8961C30.4659 49.6441 30.8659 50.3381 31.5159 50.7081L47.0739 59.6901L41.6879 62.8001C41.6339 62.8361 41.5659 62.8421 41.5059 62.8161L28.6219 55.3721C22.8959 52.0561 20.9319 44.7301 24.2319 38.9941L24.2339 38.9921ZM68.4859 49.2901L52.9279 40.3061L58.3139 37.1981C58.3679 37.1621 58.4359 37.1561 58.4959 37.1821L71.3799 44.6201C77.1159 47.9341 79.0819 55.2721 75.7679 61.0081C74.3659 63.4361 72.1579 65.2961 69.5279 66.2641V51.1021C69.5339 50.3541 69.1359 49.6621 68.4879 49.2901H68.4859ZM73.8459 41.2221C73.7519 41.1641 73.5859 41.0641 73.4679 40.9961L60.7239 33.6361C60.0779 33.2581 59.2779 33.2581 58.6299 33.6361L43.0719 42.6201V36.4001C43.0679 36.3361 43.0979 36.2741 43.1479 36.2341L56.0299 28.8021C61.7679 25.4921 69.1039 27.4621 72.4119 33.2021C73.8099 35.6261 74.3159 38.4641 73.8419 41.2221H73.8459ZM40.1439 52.3081L34.7559 49.1981C34.6979 49.1701 34.6599 49.1141 34.6519 49.0501V34.1721C34.6559 27.5481 40.0299 22.1801 46.6539 22.1841C49.4559 22.1841 52.1679 23.1681 54.3219 24.9601C54.2239 25.0121 54.0559 25.1061 53.9439 25.1741L41.1999 32.5341C40.5479 32.9041 40.1479 33.5961 40.1519 34.3461L40.1439 52.3041V52.3081ZM43.0699 46.0001L49.9999 41.9981L56.9299 45.9981V54.0001L49.9999 58.0001L43.0699 54.0001V46.0001Z" fill="black"/> 4 | </g> 5 | <defs> 6 | <clipPath id="clip0_278_47"> 7 | <rect width="64" height="64" fill="white" transform="translate(18 18)"/> 8 | </clipPath> 9 | </defs> 10 | </svg> 11 | -------------------------------------------------------------------------------- /public/providers/logos/xai.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg width="727.27" height="778.68" version="1.1" viewBox="0 0 727.27 778.68" xmlns="http://www.w3.org/2000/svg"> 3 | <polygon transform="translate(-134,-113.32)" points="508.67 574.07 761.27 213.32 639.19 213.32 447.64 486.9"/> 4 | <polygon transform="translate(-134,-113.32)" points="356.08 792 417.12 704.83 356.08 617.66 234 792"/> 5 | <polygon transform="translate(-134,-113.32)" points="508.67 792 630.75 792 356.08 399.72 234 399.72"/> 6 | <polygon transform="translate(-134,-113.32)" points="761.27 256.91 661.27 399.72 671.27 792 751.27 792"/> 7 | </svg> 8 | -------------------------------------------------------------------------------- /public/screenshot-2025-05-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miurla/morphic/4800cae1f6882c2dd95a050536cfba59fee05b0a/public/screenshot-2025-05-04.png -------------------------------------------------------------------------------- /searxng-limiter.toml: -------------------------------------------------------------------------------- 1 | #https://docs.searxng.org/admin/searx.limiter.html -------------------------------------------------------------------------------- /searxng-settings.yml: -------------------------------------------------------------------------------- 1 | use_default_settings: true 2 | server: 3 | # Is overwritten by ${SEARXNG_PORT} and ${SEARXNG_BIND_ADDRESS} 4 | port: 8888 5 | bind_address: '0.0.0.0' 6 | # public URL of the instance, to ensure correct inbound links. Is overwritten 7 | # by ${SEARXNG_URL}. 8 | base_url: false # "http://example.com/location" 9 | # rate limit the number of request on the instance, block some bots. 10 | # Is overwritten by ${SEARXNG_LIMITER} 11 | limiter: false 12 | # enable features designed only for public instances. 13 | # Is overwritten by ${SEARXNG_PUBLIC_INSTANCE} 14 | public_instance: false 15 | 16 | # If your instance owns a /etc/searxng/settings.yml file, then set the following 17 | # values there. 18 | 19 | secret_key: 'ursecretkey' # Is overwritten by ${SEARXNG_SECRET} 20 | # Proxy image results through SearXNG. Is overwritten by ${SEARXNG_IMAGE_PROXY} 21 | image_proxy: false 22 | # 1.0 and 1.1 are supported 23 | http_protocol_version: '1.0' 24 | # POST queries are more secure as they don't show up in history but may cause 25 | # problems when using Firefox containers 26 | method: 'POST' 27 | default_http_headers: 28 | X-Content-Type-Options: nosniff 29 | X-Download-Options: noopen 30 | X-Robots-Tag: noindex, nofollow 31 | Referrer-Policy: no-referrer 32 | 33 | search: 34 | formats: 35 | - json -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | --------------------------------------------------------------------------------