├── .cursorrules
├── .env
├── .github
├── ISSUE_TEMPLATE
│ └── feedback.yml
├── dependabot.yml
└── workflows
│ ├── deploy-docs.yml
│ ├── docker-image.yml
│ ├── issue-manager.yml
│ ├── latest-changes.yml
│ └── smokeshow.yml
├── .gitignore
├── .pre-commit-config.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── backend
├── .dockerignore
├── .gitignore
├── README.md
├── alembic.ini
├── app
│ ├── __init__.py
│ ├── alembic
│ │ ├── README
│ │ ├── env.py
│ │ ├── script.py.mako
│ │ └── versions
│ │ │ ├── .keep
│ │ │ ├── 6415c4591871_feat_adding_support_for_votes_documents_.py
│ │ │ ├── d0b3286c7bc5_fix_adding_mcp_template_and_server_.py
│ │ │ └── e5a288368061_init_tables.py
│ ├── analytics.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── deps.py
│ │ ├── main.py
│ │ └── routes
│ │ │ ├── __init__.py
│ │ │ ├── chats.py
│ │ │ ├── documents.py
│ │ │ ├── files.py
│ │ │ ├── items.py
│ │ │ ├── llm.py
│ │ │ ├── login.py
│ │ │ ├── logs.py
│ │ │ ├── mcp
│ │ │ ├── servers.py
│ │ │ └── templates.py
│ │ │ ├── messages.py
│ │ │ ├── projects.py
│ │ │ ├── secrets.py
│ │ │ ├── suggestions.py
│ │ │ ├── teams.py
│ │ │ ├── users.py
│ │ │ ├── utils.py
│ │ │ └── votes.py
│ ├── backend_pre_start.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── db.py
│ │ ├── logger.py
│ │ └── security.py
│ ├── crud.py
│ ├── data
│ │ ├── __init__.py
│ │ └── mcp_templates
│ │ │ ├── __init__.py
│ │ │ ├── aws.json
│ │ │ ├── github.json
│ │ │ ├── hubspot.json
│ │ │ ├── playwright.json
│ │ │ └── upstash.json
│ ├── email-templates
│ │ ├── build
│ │ │ ├── new_account.html
│ │ │ ├── reset_password.html
│ │ │ └── test_email.html
│ │ └── src
│ │ │ ├── new_account.mjml
│ │ │ ├── reset_password.mjml
│ │ │ └── test_email.mjml
│ ├── initial_data.py
│ ├── main.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── manager.py
│ │ ├── openapi
│ │ │ ├── executor.py
│ │ │ ├── github
│ │ │ │ └── tools.json
│ │ │ ├── misc
│ │ │ │ └── tools.json
│ │ │ └── schema_to_func.py
│ │ └── proxy.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chat.py
│ │ ├── document.py
│ │ ├── item.py
│ │ ├── mcp
│ │ │ ├── __init__.py
│ │ │ ├── server.py
│ │ │ └── template.py
│ │ ├── message.py
│ │ ├── project.py
│ │ ├── secret.py
│ │ ├── suggestion.py
│ │ ├── team.py
│ │ ├── user.py
│ │ ├── utils.py
│ │ └── vote.py
│ ├── services
│ │ ├── api_search_service.py
│ │ ├── file_service.py
│ │ └── utils.py
│ ├── tests
│ │ ├── __init__.py
│ │ ├── api
│ │ │ ├── __init__.py
│ │ │ └── routes
│ │ │ │ ├── __init__.py
│ │ │ │ ├── test_chats.py
│ │ │ │ ├── test_documents.py
│ │ │ │ ├── test_items.py
│ │ │ │ ├── test_login.py
│ │ │ │ ├── test_mcp_servers.py
│ │ │ │ ├── test_messages.py
│ │ │ │ ├── test_projects.py
│ │ │ │ ├── test_secrets.py
│ │ │ │ ├── test_suggestions.py
│ │ │ │ ├── test_teams.py
│ │ │ │ ├── test_users.py
│ │ │ │ └── test_votes.py
│ │ ├── conftest.py
│ │ ├── mcp
│ │ │ ├── __init__.py
│ │ │ ├── conftest.py
│ │ │ ├── test_executor.py
│ │ │ ├── test_mcp_manager.py
│ │ │ ├── test_mcp_server.py
│ │ │ └── test_schema_to_func.py
│ │ ├── scripts
│ │ │ ├── __init__.py
│ │ │ └── test_backend_pre_start.py
│ │ └── utils
│ │ │ ├── __init__.py
│ │ │ ├── chat.py
│ │ │ ├── item.py
│ │ │ ├── project.py
│ │ │ ├── secret.py
│ │ │ ├── setting.py
│ │ │ ├── team.py
│ │ │ ├── tool_call.py
│ │ │ ├── user.py
│ │ │ └── utils.py
│ ├── tests_pre_start.py
│ ├── utils.py
│ └── wait_for_db.py
├── poetry.lock
├── prestart.sh
├── pyproject.toml
├── scripts
│ ├── format-imports.sh
│ ├── format.sh
│ ├── lint.sh
│ └── test.sh
├── tests-start.sh
└── uv.lock
├── demo.gif
├── docker-compose.yml
├── docs
├── .eslintrc.json
├── .gitignore
├── README.md
├── app
│ ├── (docs)
│ │ ├── [[...slug]]
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api
│ │ └── search
│ │ │ └── route.ts
│ ├── global.css
│ ├── home
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── layout.config.tsx
│ └── layout.tsx
├── components.json
├── components
│ ├── github-stats.tsx
│ ├── header.tsx
│ ├── mobile-nav.tsx
│ ├── theme-toggle.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── dropdown-menu.tsx
│ │ └── sheet.tsx
├── content
│ └── docs
│ │ ├── index.mdx
│ │ └── test.mdx
├── lib
│ ├── source.ts
│ └── utils.ts
├── mdx-components.tsx
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── source.config.ts
├── tsconfig.json
└── wrangler.toml
├── entrypoint.sh
├── frontend
├── .env.example
├── .eslintrc.json
├── .github
│ └── workflows
│ │ ├── lint.yml
│ │ └── playwright.yml
├── .gitignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── LICENSE
├── README.md
├── app
│ ├── (auth)
│ │ ├── actions.ts
│ │ ├── api
│ │ │ └── auth
│ │ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ ├── auth.config.ts
│ │ ├── auth.ts
│ │ ├── login
│ │ │ └── page.tsx
│ │ └── register
│ │ │ └── page.tsx
│ ├── (chat)
│ │ ├── actions.ts
│ │ ├── api
│ │ │ ├── chat
│ │ │ │ └── route.ts
│ │ │ ├── document
│ │ │ │ └── route.ts
│ │ │ ├── files
│ │ │ │ └── upload
│ │ │ │ │ └── route.ts
│ │ │ ├── history
│ │ │ │ └── route.ts
│ │ │ ├── suggestions
│ │ │ │ └── route.ts
│ │ │ └── vote
│ │ │ │ └── route.ts
│ │ ├── chat
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── opengraph-image.png
│ │ ├── page.tsx
│ │ └── twitter-image.png
│ ├── (core)
│ │ ├── api
│ │ │ ├── logs
│ │ │ │ ├── route.ts
│ │ │ │ └── stream
│ │ │ │ │ └── route.ts
│ │ │ └── mcp
│ │ │ │ ├── servers
│ │ │ │ ├── [id]
│ │ │ │ │ ├── [action]
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ │ └── templates
│ │ │ │ ├── [id]
│ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ ├── components
│ │ │ ├── app-card.tsx
│ │ │ ├── app-header.tsx
│ │ │ ├── connection-form.tsx
│ │ │ ├── connections-tab.tsx
│ │ │ ├── create-connection-card.tsx
│ │ │ ├── environment-variable-editor.tsx
│ │ │ ├── install-template-modal.tsx
│ │ │ ├── log-viewer.tsx
│ │ │ ├── mcp-server-card.tsx
│ │ │ ├── mcp-server-connection-modal.tsx
│ │ │ ├── mcp-server-header.tsx
│ │ │ ├── mcp-template-card.tsx
│ │ │ ├── mcp-template-header.tsx
│ │ │ ├── schema-dialog.tsx
│ │ │ ├── secrets-tab.tsx
│ │ │ ├── states.tsx
│ │ │ ├── tool-card.tsx
│ │ │ └── tools-tab.tsx
│ │ ├── hooks
│ │ │ ├── use-connections.ts
│ │ │ ├── use-logs.ts
│ │ │ ├── use-mcp-servers.ts
│ │ │ └── use-mcp-templates.ts
│ │ ├── layout.tsx
│ │ ├── logs
│ │ │ └── page.tsx
│ │ ├── mcp
│ │ │ ├── servers
│ │ │ │ ├── [id]
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ └── templates
│ │ │ │ ├── [id]
│ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── artifacts
│ ├── actions.ts
│ ├── code
│ │ ├── client.tsx
│ │ └── server.ts
│ ├── image
│ │ ├── client.tsx
│ │ └── server.ts
│ ├── sheet
│ │ ├── client.tsx
│ │ └── server.ts
│ └── text
│ │ ├── client.tsx
│ │ └── server.ts
├── biome.jsonc
├── components.json
├── components
│ ├── app-sidebar.tsx
│ ├── artifact-actions.tsx
│ ├── artifact-close-button.tsx
│ ├── artifact-messages.tsx
│ ├── artifact.tsx
│ ├── auth-form.tsx
│ ├── chat-header.tsx
│ ├── chat.tsx
│ ├── code-block.tsx
│ ├── code-editor.tsx
│ ├── connection-header.tsx
│ ├── console.tsx
│ ├── create-artifact.tsx
│ ├── data-stream-handler.tsx
│ ├── diffview.tsx
│ ├── document-preview.tsx
│ ├── document-skeleton.tsx
│ ├── document.tsx
│ ├── icons.tsx
│ ├── image-editor.tsx
│ ├── markdown.tsx
│ ├── message-actions.tsx
│ ├── message-editor.tsx
│ ├── message-reasoning.tsx
│ ├── message.tsx
│ ├── messages.tsx
│ ├── model-selector.tsx
│ ├── multimodal-input.tsx
│ ├── nav-documents.tsx
│ ├── nav-main.tsx
│ ├── nav-secondary.tsx
│ ├── overview.tsx
│ ├── preview-attachment.tsx
│ ├── sheet-editor.tsx
│ ├── sidebar-history.tsx
│ ├── sidebar-toggle.tsx
│ ├── sidebar-user-nav.tsx
│ ├── sign-out-form.tsx
│ ├── submit-button.tsx
│ ├── suggested-actions.tsx
│ ├── suggestion.tsx
│ ├── text-editor.tsx
│ ├── theme-provider.tsx
│ ├── toast.tsx
│ ├── toolbar.tsx
│ ├── ui
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ ├── use-scroll-to-bottom.ts
│ ├── version-footer.tsx
│ ├── visibility-selector.tsx
│ └── weather.tsx
├── docs
│ ├── 01-quick-start.md
│ ├── 02-update-models.md
│ ├── 03-artifacts.md
│ └── 04-migrate-to-parts.md
├── drizzle.config.ts
├── hooks
│ ├── use-artifact.ts
│ ├── use-chat-visibility.ts
│ └── use-mobile.tsx
├── lib
│ ├── ai
│ │ ├── models.test.ts
│ │ ├── models.ts
│ │ ├── prompts.ts
│ │ ├── providers.ts
│ │ └── tools
│ │ │ ├── create-document.ts
│ │ │ ├── get-weather.ts
│ │ │ ├── request-suggestions.ts
│ │ │ └── update-document.ts
│ ├── artifacts
│ │ └── server.ts
│ ├── constants.ts
│ ├── db
│ │ ├── helpers
│ │ │ └── 01-core-to-parts.ts
│ │ ├── migrate.ts
│ │ ├── migrations
│ │ │ ├── 0000_keen_devos.sql
│ │ │ ├── 0001_sparkling_blue_marvel.sql
│ │ │ ├── 0002_wandering_riptide.sql
│ │ │ ├── 0003_cloudy_glorian.sql
│ │ │ ├── 0004_odd_slayback.sql
│ │ │ ├── 0005_wooden_whistler.sql
│ │ │ └── meta
│ │ │ │ ├── 0000_snapshot.json
│ │ │ │ ├── 0001_snapshot.json
│ │ │ │ ├── 0002_snapshot.json
│ │ │ │ ├── 0003_snapshot.json
│ │ │ │ ├── 0004_snapshot.json
│ │ │ │ ├── 0005_snapshot.json
│ │ │ │ └── _journal.json
│ │ ├── queries.ts
│ │ └── schema.ts
│ ├── editor
│ │ ├── config.ts
│ │ ├── diff.js
│ │ ├── functions.tsx
│ │ ├── react-renderer.tsx
│ │ └── suggestions.tsx
│ ├── registry.ts
│ └── utils.ts
├── middleware.ts
├── next-env.d.ts
├── next.config.ts
├── package.json
├── playwright.config.ts
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
│ └── images
│ │ ├── demo-thumbnail.png
│ │ └── mouth of the seine, monet.jpg
├── tailwind.config.ts
├── tests
│ ├── artifacts.test.ts
│ ├── auth.setup.ts
│ ├── auth.test.ts
│ ├── chat.test.ts
│ ├── pages
│ │ ├── artifact.ts
│ │ └── chat.ts
│ ├── prompts
│ │ ├── basic.ts
│ │ └── utils.ts
│ ├── reasoning.setup.ts
│ └── reasoning.test.ts
└── tsconfig.json
└── start.sh
/.env:
--------------------------------------------------------------------------------
1 | # Domain
2 | # This would be set to the production domain with an env var on deployment
3 | DOMAIN=
4 |
5 | # Environment: local, staging, production
6 | ENVIRONMENT=
7 |
8 | PROJECT_NAME=Centroid
9 | STACK_NAME=Centroid
10 |
11 | # Backend
12 | BACKEND_CORS_ORIGINS=
13 | SECRET_KEY=yBUzteofjwxyj4b3RLGJGntojhb8B_i0mt2Oy7T-gIU
14 | FIRST_SUPERUSER=admin@centroid.run
15 | FIRST_SUPERUSER_PASSWORD=centroid123
16 | USERS_OPEN_REGISTRATION=False
17 |
18 | # Emails
19 | SMTP_HOST=
20 | SMTP_USER=
21 | SMTP_PASSWORD=
22 | EMAILS_FROM_EMAIL=info@centroid.run
23 | SMTP_TLS=True
24 | SMTP_SSL=False
25 | SMTP_PORT=587
26 |
27 | DB_TYPE=sqlite
28 | LLM_BASE_URL=
29 | # # Postgres (set these values if you want to use postgres on your local setup)
30 | POSTGRES_SERVER=
31 | POSTGRES_PORT=
32 | POSTGRES_DB=
33 | POSTGRES_USER=
34 | POSTGRES_PASSWORD=
35 |
36 | SENTRY_DSN=
37 |
38 | # Configure these with your own Docker registry images
39 | DOCKER_IMAGE_BACKEND=backend
40 | DOCKER_IMAGE_FRONTEND=frontend
41 | TELEMETRY_ENABLED=true
42 |
43 | # Amplitude Key
44 | NEXT_PUBLIC_DISABLE_TELEMETRY=false
45 |
46 | NEXT_PUBLIC_DEFAULT_USER_EMAIL=${FIRST_SUPERUSER}
47 | NEXT_PUBLIC_DEFAULT_USER_PASSWORD=${FIRST_SUPERUSER_PASSWORD}
48 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 | commit-message:
9 | prefix: ⬆
10 | # Python
11 | - package-ecosystem: "pip"
12 | directory: "/"
13 | schedule:
14 | interval: "daily"
15 | commit-message:
16 | prefix: ⬆
17 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | deploy-docs:
10 | runs-on: ubuntu-latest
11 | name: Deploy Docs
12 | defaults:
13 | run:
14 | working-directory: docs
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Setup pnpm
19 | uses: pnpm/action-setup@v4
20 | with:
21 | version: 8
22 |
23 | - name: Install dependencies
24 | run: pnpm install
25 |
26 | - name: Build
27 | run: pnpm build
28 |
29 | - name: Deploy Docs
30 | uses: cloudflare/wrangler-action@v3
31 | with:
32 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
33 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
34 | workingDirectory: docs
35 | command: pages deploy out --project-name=centroid
36 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Build and Publish
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | tags: ['v*']
7 |
8 | env:
9 | REGISTRY: ghcr.io
10 | IMAGE_NAME: ${{ github.repository }}
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | contents: read
17 | packages: write
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 |
23 | - name: Set up Docker Buildx
24 | uses: docker/setup-buildx-action@v3
25 |
26 | - name: Login to GitHub Container Registry
27 | uses: docker/login-action@v3
28 | with:
29 | registry: ${{ env.REGISTRY }}
30 | username: ${{ github.actor }}
31 | password: ${{ secrets.GITHUB_TOKEN }}
32 |
33 | - name: Extract Docker metadata
34 | id: meta
35 | uses: docker/metadata-action@v5
36 | with:
37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
38 | tags: |
39 | type=ref,event=branch
40 | type=ref,event=pr
41 | type=semver,pattern={{version}}
42 | type=sha,format=long
43 |
44 | - name: Build and push Docker image
45 | uses: docker/build-push-action@v6
46 | with:
47 | context: .
48 | push: ${{ github.event_name != 'pull_request' }}
49 | platforms: linux/amd64,linux/arm64
50 | tags: ${{ steps.meta.outputs.tags }}
51 | labels: ${{ steps.meta.outputs.labels }}
52 | cache-from: type=gha
53 | cache-to: type=gha,mode=max
54 | build-args: |
55 | BUILD_HASH=${{ github.sha }}
56 |
--------------------------------------------------------------------------------
/.github/workflows/issue-manager.yml:
--------------------------------------------------------------------------------
1 | name: Issue Manager
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *"
6 | issue_comment:
7 | types:
8 | - created
9 | - edited
10 | issues:
11 | types:
12 | - labeled
13 |
14 | jobs:
15 | issue-manager:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: tiangolo/issue-manager@0.5.1
19 | with:
20 | token: ${{ secrets.GITHUB_TOKEN }}
21 | config: >
22 | {
23 | "answered": {
24 | "users": ["tiangolo"],
25 | "delay": 864000,
26 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues."
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/latest-changes.yml:
--------------------------------------------------------------------------------
1 | name: Latest Changes
2 |
3 | on:
4 | pull_request_target:
5 | branches:
6 | - master
7 | types:
8 | - closed
9 | workflow_dispatch:
10 | inputs:
11 | number:
12 | description: PR number
13 | required: true
14 | debug_enabled:
15 | description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)"
16 | required: false
17 | default: "false"
18 |
19 | jobs:
20 | latest-changes:
21 | runs-on: ubuntu-latest
22 | steps:
23 | - name: Dump GitHub context
24 | env:
25 | GITHUB_CONTEXT: ${{ toJson(github) }}
26 | run: echo "$GITHUB_CONTEXT"
27 | - uses: actions/checkout@v4
28 | with:
29 | # To allow latest-changes to commit to the main branch
30 | token: ${{ secrets.FULL_STACK_FASTAPI_POSTGRESQL_LATEST_CHANGES }}
31 | - uses: docker://tiangolo/latest-changes:0.3.0
32 | # - uses: tiangolo/latest-changes@main
33 | with:
34 | token: ${{ secrets.GITHUB_TOKEN }}
35 | latest_changes_file: ./release-notes.md
36 | latest_changes_header: "## Latest Changes"
37 | end_regex: "^## "
38 | debug_logs: true
39 | label_header_prefix: "### "
40 |
--------------------------------------------------------------------------------
/.github/workflows/smokeshow.yml:
--------------------------------------------------------------------------------
1 | name: Smokeshow
2 |
3 | on:
4 | workflow_run:
5 | workflows: [Test]
6 | types: [completed]
7 |
8 | permissions:
9 | statuses: write
10 |
11 | jobs:
12 | smokeshow:
13 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/setup-python@v5
18 | with:
19 | python-version: "3.9"
20 |
21 | - run: pip install smokeshow
22 |
23 | - uses: actions/download-artifact@v4
24 | with:
25 | workflow: test.yml
26 | commit: ${{ github.event.workflow_run.head_sha }}
27 |
28 | - run: smokeshow upload backend/htmlcov
29 | env:
30 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage}
31 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 90
32 | SMOKESHOW_GITHUB_CONTEXT: coverage
33 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
35 | SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }}
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .env
3 | .env*
4 | .env.local
5 | .env.development
6 | .env.production
7 | playwright/.auth
8 | __pycache__
9 | app.egg-info
10 | *.pyc
11 | .mypy_cache
12 | .coverage
13 | htmlcov
14 | .cache
15 | .venv
16 | logs/*
17 | .history/*
18 | .DS_Store
19 | .idea/*
20 | .pytest_cache/*
21 | .ruff_cache/*
22 | .mypy_cache/*
23 | .coverage/*
24 | **/.history/*
25 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v4.4.0
6 | hooks:
7 | - id: check-added-large-files
8 | - id: check-toml
9 | - id: check-yaml
10 | args:
11 | - --unsafe
12 | - id: end-of-file-fixer
13 | - id: trailing-whitespace
14 | - repo: https://github.com/charliermarsh/ruff-pre-commit
15 | rev: v0.2.2
16 | hooks:
17 | - id: ruff
18 | args:
19 | - --fix
20 | - id: ruff-format
21 | # - repo: https://github.com/pre-commit/mirrors-prettier
22 | # rev: "v3.1.0"
23 | # hooks:
24 | # - id: prettier
25 | # args: [--write, .]
26 | # - repo: https://github.com/pre-commit/mirrors-eslint
27 | # rev: "v7.11.0"
28 | # hooks:
29 | # - id: eslint
30 | # args: [--fix, .]
31 | ci:
32 | autofix_commit_msg: '🎨 [pre-commit.ci] Auto format from pre-commit.com hooks'
33 | autoupdate_commit_msg: '⬆ [pre-commit.ci] pre-commit autoupdate'
34 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | # Python
2 | __pycache__
3 | app.egg-info
4 | *.pyc
5 | .mypy_cache
6 | .coverage
7 | htmlcov
8 | .venv
9 | .env
10 | .env.local
11 | .env.staging
12 | .env.production
13 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | app.egg-info
3 | *.pyc
4 | .mypy_cache
5 | .coverage
6 | htmlcov
7 | .cache
8 | .venv
9 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/README.md
--------------------------------------------------------------------------------
/backend/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = app/alembic
6 |
7 | # template used to generate migration files
8 | # file_template = %%(rev)s_%%(slug)s
9 |
10 | # timezone to use when rendering the date
11 | # within the migration file as well as the filename.
12 | # string value is passed to dateutil.tz.gettz()
13 | # leave blank for localtime
14 | # timezone =
15 |
16 | # max length of characters to apply to the
17 | # "slug" field
18 | #truncate_slug_length = 40
19 |
20 | # set to 'true' to run the environment during
21 | # the 'revision' command, regardless of autogenerate
22 | # revision_environment = false
23 |
24 | # set to 'true' to allow .pyc and .pyo files without
25 | # a source .py file to be detected as revisions in the
26 | # versions/ directory
27 | # sourceless = false
28 |
29 | # version location specification; this defaults
30 | # to alembic/versions. When using multiple version
31 | # directories, initial revisions must be specified with --version-path
32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions
33 |
34 | # the output encoding used when revision files
35 | # are written from script.py.mako
36 | # output_encoding = utf-8
37 |
38 | # Logging configuration
39 | [loggers]
40 | keys = root,sqlalchemy,alembic
41 |
42 | [handlers]
43 | keys = console
44 |
45 | [formatters]
46 | keys = generic
47 |
48 | [logger_root]
49 | level = WARN
50 | handlers = console
51 | qualname =
52 |
53 | [logger_sqlalchemy]
54 | level = WARN
55 | handlers =
56 | qualname = sqlalchemy.engine
57 |
58 | [logger_alembic]
59 | level = INFO
60 | handlers =
61 | qualname = alembic
62 |
63 | [handler_console]
64 | class = StreamHandler
65 | args = (sys.stderr,)
66 | level = NOTSET
67 | formatter = generic
68 |
69 | [formatter_generic]
70 | format = %(levelname)-5.5s [%(name)s] %(message)s
71 | datefmt = %H:%M:%S
72 |
--------------------------------------------------------------------------------
/backend/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/__init__.py
--------------------------------------------------------------------------------
/backend/app/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/backend/app/alembic/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 | ${imports if imports else ""}
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = ${repr(up_revision)}
15 | down_revision = ${repr(down_revision)}
16 | branch_labels = ${repr(branch_labels)}
17 | depends_on = ${repr(depends_on)}
18 |
19 |
20 | def upgrade():
21 | ${upgrades if upgrades else "pass"}
22 |
23 |
24 | def downgrade():
25 | ${downgrades if downgrades else "pass"}
26 |
--------------------------------------------------------------------------------
/backend/app/alembic/versions/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/alembic/versions/.keep
--------------------------------------------------------------------------------
/backend/app/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/api/__init__.py
--------------------------------------------------------------------------------
/backend/app/api/deps.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Generator
2 | from typing import Annotated
3 |
4 | import jwt
5 | from fastapi import Depends, HTTPException, status
6 | from fastapi.security import OAuth2PasswordBearer
7 | from jwt.exceptions import InvalidTokenError
8 | from pydantic import ValidationError
9 | from sqlmodel import Session
10 |
11 | from app.core import security
12 | from app.core.config import settings
13 | from app.core.db import engine
14 | from app.models import TokenPayload, User
15 |
16 | reusable_oauth2 = OAuth2PasswordBearer(
17 | tokenUrl=f"{settings.API_V1_STR}/login/access-token"
18 | )
19 |
20 |
21 | def get_db() -> Generator[Session, None, None]:
22 | with Session(engine) as session:
23 | yield session
24 |
25 |
26 | SessionDep = Annotated[Session, Depends(get_db)]
27 | TokenDep = Annotated[str, Depends(reusable_oauth2)]
28 |
29 |
30 | def get_current_user(session: SessionDep, token: TokenDep) -> User:
31 | try:
32 | payload = jwt.decode(
33 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
34 | )
35 | token_data = TokenPayload(**payload)
36 | except (InvalidTokenError, ValidationError):
37 | raise HTTPException(
38 | status_code=status.HTTP_403_FORBIDDEN,
39 | detail="Could not validate credentials",
40 | )
41 | user = session.get(User, token_data.sub)
42 | if not user:
43 | raise HTTPException(status_code=401, detail="User not authenticated/authorized")
44 | if not user.is_active:
45 | raise HTTPException(status_code=400, detail="Inactive user")
46 | return user
47 |
48 |
49 | CurrentUser = Annotated[User, Depends(get_current_user)]
50 |
51 |
52 | def get_current_active_superuser(current_user: CurrentUser) -> User:
53 | if not current_user.is_superuser:
54 | raise HTTPException(
55 | status_code=403, detail="The user doesn't have enough privileges"
56 | )
57 | return current_user
58 |
--------------------------------------------------------------------------------
/backend/app/api/main.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from fastapi import APIRouter
4 |
5 | from app.analytics import AnalyticsService
6 | from app.api.routes import (
7 | chats,
8 | documents,
9 | files,
10 | items,
11 | llm,
12 | login,
13 | logs,
14 | messages,
15 | projects,
16 | suggestions,
17 | teams,
18 | users,
19 | utils,
20 | votes,
21 | )
22 | from app.api.routes.mcp import servers, templates
23 | from app.core.logger import get_logger
24 |
25 | # Initialize analytics service and generate instance ID
26 | analytics_service = AnalyticsService()
27 | INSTANCE_ID = str(uuid.uuid4()) # Generate once at startup
28 |
29 | # Setup logger using custom logger
30 | logger = get_logger(__name__)
31 |
32 | api_router = APIRouter()
33 |
34 |
35 | # Include all routers
36 | api_router.include_router(login.router, tags=["login"])
37 | api_router.include_router(users.router, prefix="/users", tags=["users"])
38 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
39 | api_router.include_router(items.router, prefix="/items", tags=["items"])
40 | api_router.include_router(chats.router, prefix="/chats", tags=["chats"])
41 | api_router.include_router(documents.router, prefix="/documents", tags=["documents"])
42 | api_router.include_router(messages.router, prefix="/messages", tags=["messages"])
43 | api_router.include_router(
44 | suggestions.router, prefix="/suggestions", tags=["suggestions"]
45 | )
46 | api_router.include_router(votes.router, prefix="/votes", tags=["votes"])
47 | api_router.include_router(teams.router, prefix="/teams", tags=["teams"])
48 | api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
49 | api_router.include_router(files.router, prefix="/files", tags=["files"])
50 | api_router.include_router(llm.router, prefix="/llm", tags=["llm"])
51 | api_router.include_router(logs.router, prefix="/logs", tags=["logs"])
52 |
53 |
54 | api_router.include_router(
55 | servers.router, prefix="/mcp/servers", tags=["mcp", "servers"]
56 | )
57 | api_router.include_router(
58 | templates.router, prefix="/mcp/templates", tags=["mcp", "templates"]
59 | )
60 |
--------------------------------------------------------------------------------
/backend/app/api/routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/api/routes/__init__.py
--------------------------------------------------------------------------------
/backend/app/api/routes/llm.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from fastapi import APIRouter, HTTPException
3 | from pydantic import BaseModel, computed_field
4 |
5 | from app.core.config import settings
6 |
7 | router = APIRouter()
8 |
9 |
10 | class ModelInfo(BaseModel):
11 | id: str
12 | name: str | None = None
13 | object: str | None = None
14 | created: int | None = None
15 | owned_by: str | None = None
16 |
17 | @computed_field
18 | def label(self) -> str:
19 | """Returns a user-friendly label for the model"""
20 | # Use name if available, otherwise format the id
21 | if self.name:
22 | return self.name
23 | return self.id.replace("-", " ").title()
24 |
25 | @computed_field
26 | def is_default(self) -> bool:
27 | """Returns whether this is the default model"""
28 | return self.id == settings.LLM_DEFAULT_MODEL
29 |
30 |
31 | class ModelsResponse(BaseModel):
32 | object: str | None = None # Added to match API response
33 | data: list[ModelInfo]
34 |
35 |
36 | @router.get("/models", response_model=ModelsResponse)
37 | async def get_models():
38 | """
39 | Fetch available models from the LLM service
40 | """
41 | try:
42 | headers = {"Authorization": f"Bearer {settings.LLM_API_KEY}"}
43 | # Ensure the base URL doesn't end with a slash and append v1/models
44 | models_url = f"{settings.LLM_BASE_URL.rstrip('/')}/models"
45 |
46 | async with httpx.AsyncClient() as client:
47 | response = await client.get(models_url, headers=headers)
48 | response.raise_for_status()
49 | return response.json()
50 | except httpx.HTTPError as e:
51 | raise HTTPException(
52 | status_code=500, detail=f"Failed to fetch models from LLM service: {str(e)}"
53 | )
54 |
--------------------------------------------------------------------------------
/backend/app/api/routes/utils.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from pydantic.networks import EmailStr
3 |
4 | from app.api.deps import get_current_active_superuser
5 | from app.models import UtilsMessage
6 | from app.utils import generate_test_email, send_email
7 |
8 | router = APIRouter()
9 |
10 |
11 | @router.post(
12 | "/test-email/",
13 | dependencies=[Depends(get_current_active_superuser)],
14 | status_code=201,
15 | )
16 | def test_email(email_to: EmailStr) -> UtilsMessage:
17 | """
18 | Test emails.
19 | """
20 | email_data = generate_test_email(email_to=email_to)
21 | send_email(
22 | email_to=email_to,
23 | subject=email_data.subject,
24 | html_content=email_data.html_content,
25 | )
26 | return UtilsMessage(message="Test email sent")
27 |
28 |
29 | @router.post(
30 | "/hello",
31 | )
32 | def test_sample() -> UtilsMessage:
33 | """
34 | Test emails.
35 | """
36 | return UtilsMessage(message="Hello World!")
37 |
38 |
39 | @router.get("/health")
40 | def health_check() -> UtilsMessage:
41 | """
42 | Health check endpoint to verify API is running.
43 | """
44 | return UtilsMessage(message="OK")
45 |
--------------------------------------------------------------------------------
/backend/app/backend_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlalchemy import Engine
4 | from sqlmodel import Session, select
5 | from tenacity import before_log, retry, stop_after_attempt, wait_fixed
6 |
7 | from app.core.db import engine
8 | from app.core.logger import get_logger
9 |
10 | logger = get_logger(__name__, service="startup")
11 |
12 | max_tries = 60 * 5 # 5 minutes
13 | wait_seconds = 1
14 |
15 |
16 | @retry(
17 | stop=stop_after_attempt(max_tries),
18 | wait=wait_fixed(wait_seconds),
19 | before=before_log(logger, logging.INFO),
20 | after=before_log(logger, logging.WARN),
21 | )
22 | def init(db_engine: Engine) -> None:
23 | try:
24 | logger.info("Initializing database connection")
25 | with Session(db_engine) as session:
26 | # Try to create session to check if DB is awake
27 | session.exec(select(1))
28 | except Exception as e:
29 | logger.error(
30 | "Database connection failed",
31 | extra={"error": str(e), "attempt": max_tries, "wait_seconds": wait_seconds},
32 | exc_info=True,
33 | )
34 | raise e
35 |
36 |
37 | def main() -> None:
38 | logger.info(
39 | "Initializing service", extra={"stage": "startup", "component": "database"}
40 | )
41 | init(engine)
42 | logger.info(
43 | "Service finished initializing",
44 | extra={"stage": "startup", "status": "completed"},
45 | )
46 |
47 |
48 | if __name__ == "__main__":
49 | main()
50 |
--------------------------------------------------------------------------------
/backend/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/core/__init__.py
--------------------------------------------------------------------------------
/backend/app/data/__init__.py:
--------------------------------------------------------------------------------
1 | """Data and fixtures for the application."""
2 |
--------------------------------------------------------------------------------
/backend/app/data/mcp_templates/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP template data fixtures."""
2 |
--------------------------------------------------------------------------------
/backend/app/data/mcp_templates/hubspot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "hubspot",
3 | "name": "HubSpot",
4 | "description": "Interact with HubSpot CRM, marketing, and sales services.",
5 | "status": "active",
6 | "kind": "official",
7 | "transport": "http",
8 | "version": "1.0.0",
9 | "details": {
10 | "icon": {
11 | "path": "M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z"
12 | },
13 | "url": "https://hubspot.com",
14 | "category": "marketing",
15 | "documentation": "https://developers.hubspot.com/docs/api/overview"
16 | },
17 | "run": {
18 | "command": "npx",
19 | "args": ["@anthropic-ai/mcp-hubspot"]
20 | },
21 | "tools": []
22 | }
23 |
--------------------------------------------------------------------------------
/backend/app/data/mcp_templates/upstash.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "upstash",
3 | "name": "Upstash",
4 | "description": "Interact with Upstash Redis and Kafka services.",
5 | "status": "active",
6 | "kind": "official",
7 | "transport": "http",
8 | "version": "1.0.0",
9 | "details": {
10 | "icon": {
11 | "path": "M13.8027 0C11.193 0 8.583.9952 6.5918 2.9863c-3.9823 3.9823-3.9823 10.4396 0 14.4219 1.9911 1.9911 5.2198 1.9911 7.211 0 1.991-1.9911 1.991-5.2198 0-7.211L12 12c.9956.9956.9956 2.6098 0 3.6055-.9956.9955-2.6099.9955-3.6055 0-2.9866-2.9868-2.9866-7.8297 0-10.8164 2.9868-2.9868 7.8297-2.9868 10.8164 0l1.8028-1.8028C19.0225.9952 16.4125 0 13.8027 0zM12 12c-.9956-.9956-.9956-2.6098 0-3.6055.9956-.9955 2.6098-.9955 3.6055 0 2.9867 2.9868 2.9867 7.8297 0 10.8164-2.9867 2.9868-7.8297 2.9868-10.8164 0l-1.8028 1.8028c3.9823 3.9822 10.4396 3.9822 14.4219 0 3.9823-3.9824 3.9823-10.4396 0-14.4219-.9956-.9956-2.3006-1.4922-3.6055-1.4922-1.3048 0-2.6099.4966-3.6054 1.4922-1.9912 1.9912-1.9912 5.2198 0 7.211z"
12 | },
13 | "url": "https://upstash.com",
14 | "category": "database",
15 | "documentation": "https://docs.upstash.com"
16 | },
17 | "run": {
18 | "command": "npx",
19 | "args": ["@anthropic-ai/mcp-upstash"]
20 | },
21 | "tools": []
22 | }
23 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/new_account.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ project_name }} - New Account
8 | Welcome to your new account!
17 | Here are your account details:
26 | Username: {{ username }}
35 | Password: {{ password }}
44 | Go to Dashboard
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/test_email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ project_name }}
13 | Test email for: {{ email }}
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/backend/app/initial_data.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 |
4 | from sqlmodel import Session, select
5 |
6 | from app.core.db import engine, init_db
7 | from app.models import ( # Adjust import based on your model structure
8 | User,
9 | )
10 |
11 | logging.basicConfig(level=logging.INFO)
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | async def is_db_empty(session: Session) -> bool:
16 | # Check if any users exist
17 | result = session.exec(select(User)).first()
18 | return result is None
19 |
20 |
21 | async def init() -> None:
22 | with Session(engine) as session:
23 | # Always populate tool definitions on startup
24 | logger.info("Ensuring tool definitions are up to date...")
25 |
26 | # # Initialize other data only if database is empty
27 | # if await is_db_empty(session):
28 | # logger.info("Database is empty, initializing with seed data...")
29 | await init_db(session)
30 | # else:
31 | # logger.info("Database already contains data, skipping user initialization")
32 |
33 |
34 | async def main() -> None:
35 | logger.info("Starting database initialization")
36 | await init()
37 | logger.info("Database initialization completed")
38 |
39 |
40 | if __name__ == "__main__":
41 | asyncio.run(main())
42 |
--------------------------------------------------------------------------------
/backend/app/mcp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/mcp/__init__.py
--------------------------------------------------------------------------------
/backend/app/models/base.py:
--------------------------------------------------------------------------------
1 | from humps import camelize, decamelize
2 | from pydantic import BaseModel, ConfigDict
3 |
4 |
5 | class CamelModel(BaseModel):
6 | """Base model for all API models with camel case conversion for keys only"""
7 |
8 | model_config = ConfigDict(
9 | alias_generator=lambda x: camelize(x), # Only transform field names
10 | populate_by_name=True,
11 | from_attributes=True,
12 | )
13 |
14 | # Convert snake_case to camelCase in response
15 | def model_dump(self, *args, **kwargs):
16 | # Ensure camelCase keys in response
17 | kwargs.setdefault("by_alias", True)
18 | return super().model_dump(*args, **kwargs)
19 |
20 | # Convert camelCase to snake_case in request
21 | @classmethod
22 | def model_validate(cls, obj, *args, **kwargs):
23 | # Convert incoming camelCase keys to snake_case
24 | if isinstance(obj, dict):
25 | obj = {decamelize(k): v for k, v in obj.items()}
26 | return super().model_validate(obj, *args, **kwargs)
27 |
--------------------------------------------------------------------------------
/backend/app/models/document.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from enum import Enum
3 | from typing import ForwardRef
4 |
5 | import nanoid
6 | from sqlalchemy import DateTime, func
7 | from sqlmodel import Field, Relationship, SQLModel
8 |
9 | from .base import CamelModel
10 | from .user import User
11 |
12 |
13 | class DocumentKind(str, Enum):
14 | TEXT = "text"
15 | CODE = "code"
16 | IMAGE = "image"
17 | SHEET = "sheet"
18 |
19 |
20 | class DocumentBase(CamelModel):
21 | title: str
22 | content: str | None = None
23 | kind: DocumentKind = DocumentKind.TEXT
24 |
25 |
26 | class DocumentCreate(DocumentBase):
27 | project_id: str | None = None
28 |
29 |
30 | class DocumentUpdate(CamelModel):
31 | title: str | None = None
32 | content: str | None = None
33 | kind: DocumentKind | None = None
34 | project_id: str | None = None
35 |
36 |
37 | class Document(DocumentBase, SQLModel, table=True):
38 | __tablename__ = "documents"
39 | id: str = Field(primary_key=True, default_factory=nanoid.generate)
40 | user_id: str = Field(foreign_key="users.id")
41 | project_id: str | None = Field(default=None, foreign_key="projects.id")
42 | created_at: datetime | None = Field(
43 | default=None,
44 | sa_type=DateTime(timezone=True),
45 | sa_column_kwargs={"server_default": func.now()},
46 | )
47 | updated_at: datetime | None = Field(
48 | default=None,
49 | sa_type=DateTime(timezone=True),
50 | sa_column_kwargs={"onupdate": func.now(), "server_default": func.now()},
51 | )
52 |
53 | # Relationships
54 | user: User = Relationship(back_populates="documents")
55 | project: "Project" = Relationship(back_populates="documents") # noqa: F821
56 | suggestions: list[ForwardRef("Suggestion")] = Relationship(
57 | back_populates="document", cascade_delete=True
58 | )
59 |
60 |
61 | class DocumentOut(DocumentBase):
62 | id: str
63 | user_id: str
64 | project_id: str | None = None
65 | created_at: datetime | None = None
66 | updated_at: datetime | None = None
67 |
68 |
69 | class DocumentsOut(CamelModel):
70 | data: list[DocumentOut]
71 | count: int
72 |
--------------------------------------------------------------------------------
/backend/app/models/item.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Field, Relationship, SQLModel # Shared properties
2 |
3 | from .user import User
4 |
5 |
6 | class ItemBase(SQLModel):
7 | title: str
8 | description: str | None = None
9 |
10 |
11 | # Properties to receive on item creation
12 | class ItemCreate(ItemBase):
13 | title: str
14 |
15 |
16 | # Properties to receive on item update
17 | class ItemUpdate(ItemBase):
18 | title: str | None = None # type: ignore
19 |
20 |
21 | # Database model, database table inferred from class name
22 | class Item(ItemBase, table=True):
23 | id: int | None = Field(default=None, primary_key=True)
24 | title: str
25 | owner_id: str | None = Field(default=None, foreign_key="users.id", nullable=False)
26 | owner: User | None = Relationship(back_populates="items")
27 |
28 |
29 | # Properties to return via API, id is always required
30 | class ItemOut(ItemBase):
31 | id: int
32 | owner_id: str
33 |
34 |
35 | class ItemsOut(SQLModel):
36 | data: list[ItemOut]
37 | count: int
38 |
--------------------------------------------------------------------------------
/backend/app/models/mcp/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP-related models."""
2 |
3 | from app.models.mcp.server import (
4 | MCPRunConfig,
5 | MCPServer,
6 | MCPServerBase,
7 | MCPServerCreate,
8 | MCPServerOut,
9 | MCPServerOutWithTemplate,
10 | MCPServerSearch,
11 | MCPServersOut,
12 | MCPServersOutWithTemplate,
13 | MCPServerState,
14 | MCPServerStatus,
15 | MCPServerUpdate,
16 | MCPTool,
17 | )
18 | from app.models.mcp.template import (
19 | MCPTemplate,
20 | MCPTemplateBase,
21 | MCPTemplateCreate,
22 | MCPTemplateOut,
23 | MCPTemplatesOut,
24 | MCPTemplateUpdate,
25 | )
26 |
27 | from ..utils import generate_docker_style_name
28 |
29 | __all__ = [
30 | # Base models and utilities
31 | "MCPRunConfig",
32 | "MCPServerSearch",
33 | "MCPServerStatus",
34 | "generate_docker_style_name",
35 | "MCPTool",
36 | "MCPServerState",
37 | # Server models
38 | "MCPServerBase",
39 | "MCPServerCreate",
40 | "MCPServerUpdate",
41 | "MCPServer",
42 | "MCPServerOut",
43 | "MCPServersOut",
44 | "MCPServerOutWithTemplate",
45 | "MCPServersOutWithTemplate",
46 | # Template models
47 | "MCPTemplateBase",
48 | "MCPTemplateCreate",
49 | "MCPTemplateUpdate",
50 | "MCPTemplate",
51 | "MCPTemplateOut",
52 | "MCPTemplatesOut",
53 | ]
54 |
55 | MCPTemplateOut.model_rebuild()
56 |
--------------------------------------------------------------------------------
/backend/app/models/message.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import TYPE_CHECKING
3 |
4 | import nanoid
5 | from sqlalchemy import Column, DateTime, func
6 | from sqlmodel import JSON, Field, Relationship, SQLModel
7 |
8 | from .base import CamelModel
9 |
10 | if TYPE_CHECKING:
11 | from .chat import Chat
12 |
13 |
14 | class Message(SQLModel, table=True):
15 | __tablename__ = "messages"
16 | id: str = Field(primary_key=True, default_factory=nanoid.generate)
17 | chat_id: str = Field(foreign_key="chats.id")
18 | role: str
19 | parts: list | str | dict | None = Field(sa_column=Column(JSON))
20 | attachments: list | dict | None = Field(default=None, sa_column=Column(JSON))
21 | created_at: datetime | None = Field(
22 | default=None,
23 | sa_type=DateTime(timezone=True),
24 | sa_column_kwargs={"server_default": func.now()},
25 | )
26 | chat: "Chat" = Relationship(back_populates="messages")
27 |
28 |
29 | class MessageCreate(CamelModel):
30 | id: str = Field(default_factory=nanoid.generate)
31 | chat_id: str
32 | role: str
33 | parts: list | str | dict | None
34 | attachments: list | dict | None = None
35 |
36 |
37 | class MessageOut(CamelModel):
38 | id: str
39 | chat_id: str
40 | role: str
41 | parts: list | str | dict | None
42 | attachments: list | dict | None = None
43 | created_at: datetime | None = None
44 |
45 |
46 | class MessagesOut(CamelModel):
47 | data: list[MessageOut]
48 | count: int
49 |
--------------------------------------------------------------------------------
/backend/app/models/project.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | import nanoid
5 | from sqlalchemy import Column, DateTime, func
6 | from sqlmodel import JSON, Field, Relationship, SQLModel
7 |
8 | from .base import CamelModel
9 | from .team import Team
10 |
11 |
12 | class ProjectBase(CamelModel):
13 | title: str
14 | description: str | None = None
15 | model: str | None = None
16 | instructions: str | None = None
17 |
18 |
19 | class ProjectCreate(ProjectBase):
20 | team_id: str | None = None
21 |
22 |
23 | class ProjectUpdate(CamelModel):
24 | title: str | None = None
25 | description: str | None = None
26 | model: str | None = None
27 | instructions: str | None = None
28 | files: list[str] | None = None
29 | new_files: Any | None = None
30 | # updated_at: datetime = datetime.utcnow()
31 |
32 |
33 | class Project(ProjectBase, SQLModel, table=True):
34 | __tablename__ = "projects"
35 | id: str = Field(default_factory=nanoid.generate, primary_key=True)
36 | files: list[str] | None = Field(default=[], sa_column=Column(JSON))
37 |
38 | created_at: datetime | None = Field(
39 | default=None,
40 | sa_type=DateTime(timezone=True),
41 | sa_column_kwargs={
42 | "server_default": func.now(),
43 | },
44 | )
45 | updated_at: datetime | None = Field(
46 | default=None,
47 | sa_type=DateTime(timezone=True),
48 | sa_column_kwargs={"onupdate": func.now(), "server_default": func.now()},
49 | )
50 | team_id: str = Field(foreign_key="teams.id", nullable=False)
51 | team: Team = Relationship(back_populates="projects")
52 | chats: list["Chat"] = Relationship(back_populates="project") # noqa: F821
53 | documents: list["Document"] = Relationship(back_populates="project") # noqa: F821
54 |
55 |
56 | class ProjectOut(ProjectBase):
57 | id: str
58 | team_id: str
59 | files: list[str] | None = []
60 | created_at: datetime
61 | updated_at: datetime
62 |
63 |
64 | class ProjectsOut(CamelModel):
65 | data: list[ProjectOut]
66 | count: int
67 |
--------------------------------------------------------------------------------
/backend/app/models/suggestion.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import nanoid
4 | from sqlalchemy import DateTime, func
5 | from sqlmodel import Field, Relationship, SQLModel
6 |
7 | from .base import CamelModel
8 | from .document import Document
9 | from .user import User
10 |
11 |
12 | class SuggestionBase(CamelModel):
13 | document_id: str = Field(foreign_key="documents.id")
14 | original_text: str
15 | suggested_text: str
16 | description: str | None = None
17 | is_resolved: bool = False
18 |
19 |
20 | class SuggestionCreate(SuggestionBase):
21 | pass
22 |
23 |
24 | class SuggestionUpdate(CamelModel):
25 | suggested_text: str | None = None
26 | description: str | None = None
27 | is_resolved: bool | None = None
28 |
29 |
30 | class Suggestion(SuggestionBase, SQLModel, table=True):
31 | __tablename__ = "suggestions"
32 | id: str = Field(primary_key=True, default_factory=nanoid.generate)
33 | user_id: str = Field(foreign_key="users.id")
34 | created_at: datetime | None = Field(
35 | default=None,
36 | sa_type=DateTime(timezone=True),
37 | sa_column_kwargs={"server_default": func.now()},
38 | )
39 | updated_at: datetime | None = Field(
40 | default=None,
41 | sa_type=DateTime(timezone=True),
42 | sa_column_kwargs={"onupdate": func.now(), "server_default": func.now()},
43 | )
44 |
45 | # Relationships
46 | user: User = Relationship(back_populates="suggestions")
47 | document: Document = Relationship(back_populates="suggestions")
48 |
49 |
50 | class SuggestionOut(SuggestionBase):
51 | id: str
52 | user_id: str
53 | created_at: datetime | None = None
54 | updated_at: datetime | None = None
55 |
56 |
57 | class SuggestionsOut(CamelModel):
58 | data: list[SuggestionOut]
59 | count: int
60 |
--------------------------------------------------------------------------------
/backend/app/models/vote.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import nanoid
4 | from sqlalchemy import DateTime, func
5 | from sqlmodel import Field, Relationship, SQLModel
6 |
7 | from .base import CamelModel
8 | from .user import User
9 |
10 |
11 | class VoteBase(CamelModel):
12 | chat_id: str
13 | message_id: str
14 | is_upvoted: bool
15 |
16 |
17 | class VoteCreate(VoteBase):
18 | pass
19 |
20 |
21 | class VoteUpdate(VoteBase):
22 | is_upvoted: bool | None = None
23 |
24 |
25 | class Vote(VoteBase, SQLModel, table=True):
26 | __tablename__ = "votes"
27 | id: str = Field(primary_key=True, default_factory=nanoid.generate)
28 | user_id: str = Field(foreign_key="users.id")
29 | created_at: datetime | None = Field(
30 | default=None,
31 | sa_type=DateTime(timezone=True),
32 | sa_column_kwargs={"server_default": func.now()},
33 | )
34 | updated_at: datetime | None = Field(
35 | default=None,
36 | sa_type=DateTime(timezone=True),
37 | sa_column_kwargs={"onupdate": func.now(), "server_default": func.now()},
38 | )
39 |
40 | # Relationships
41 | user: User = Relationship(back_populates="votes")
42 | # Note: We could add a relationship to Chat and Message here if those models exist
43 | # chat: "Chat" = Relationship(back_populates="votes")
44 | # message: "Message" = Relationship(back_populates="votes")
45 |
46 |
47 | class VoteOut(VoteBase):
48 | id: str
49 | user_id: str
50 | created_at: datetime | None = None
51 | updated_at: datetime | None = None
52 |
53 |
54 | class VotesOut(CamelModel):
55 | data: list[VoteOut]
56 | count: int
57 |
--------------------------------------------------------------------------------
/backend/app/services/file_service.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | from datetime import datetime
3 | from pathlib import Path
4 |
5 | from fastapi import UploadFile
6 |
7 | from app.core.config import settings
8 | from app.core.logger import get_logger
9 |
10 | logger = get_logger(__name__, service="file_service")
11 |
12 |
13 | async def upload_files_to_project(
14 | project_id: str, files: list[UploadFile]
15 | ) -> list[str]:
16 | """
17 | Upload files to a project.
18 | Returns a list of safe filenames that were successfully uploaded.
19 | """
20 | logger.info(f"Starting file upload for project_id: {project_id}")
21 | upload_dir = ensure_upload_dir(project_id)
22 | uploaded_files = []
23 |
24 | for upload_file in files:
25 | filename = Path(upload_file.filename).name
26 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
27 | safe_filename = f"{timestamp}_{filename}"
28 | file_path = upload_dir / safe_filename
29 |
30 | try:
31 | with file_path.open("wb") as buffer:
32 | shutil.copyfileobj(upload_file.file, buffer)
33 |
34 | logger.debug(f"File saved successfully: {safe_filename}")
35 | relative_path = str(Path(project_id) / safe_filename)
36 | uploaded_files.append(relative_path)
37 |
38 | finally:
39 | upload_file.file.close()
40 |
41 | logger.info(
42 | f"Successfully uploaded {len(uploaded_files)} files to project {project_id}"
43 | )
44 | return uploaded_files
45 |
46 |
47 | def ensure_upload_dir(project_id: str) -> Path:
48 | """Create upload directory if it doesn't exist"""
49 | upload_path = Path(settings.UPLOAD_DIR) / project_id
50 | upload_path.mkdir(parents=True, exist_ok=True)
51 | return upload_path
52 |
--------------------------------------------------------------------------------
/backend/app/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/api/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/api/routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/api/routes/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from collections.abc import Generator
3 |
4 | import pytest
5 | from fastapi.testclient import TestClient
6 | from sqlmodel import Session
7 |
8 | from app.core.config import settings
9 | from app.core.db import engine, init_db
10 | from app.main import app
11 | from app.tests.utils.user import authentication_token_from_email
12 | from app.tests.utils.utils import get_superuser_token_headers
13 |
14 |
15 | @pytest.fixture(scope="session", autouse=True)
16 | def db() -> Generator[Session, None, None]:
17 | with Session(engine) as session:
18 | asyncio.run(init_db(session))
19 | yield session
20 | # Clean up in reverse order of dependencies
21 | # statement = delete(Item)
22 | # session.execute(statement)
23 | # statement = delete(User)
24 | # session.execute(statement)
25 | # session.commit()
26 |
27 |
28 | @pytest.fixture(autouse=True)
29 | def auto_rollback(db: Session) -> Generator[None, None, None]:
30 | """Automatically rollback all changes after each test."""
31 | try:
32 | yield
33 | finally:
34 | db.rollback()
35 |
36 |
37 | @pytest.fixture(scope="module")
38 | def client() -> Generator[TestClient, None, None]:
39 | with TestClient(app) as c:
40 | yield c
41 |
42 |
43 | @pytest.fixture(scope="module")
44 | def superuser_token_headers(client: TestClient) -> dict[str, str]:
45 | return get_superuser_token_headers(client)
46 |
47 |
48 | @pytest.fixture(scope="module")
49 | def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
50 | return authentication_token_from_email(
51 | client=client, email=settings.EMAIL_TEST_USER, db=db
52 | )
53 |
--------------------------------------------------------------------------------
/backend/app/tests/mcp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/mcp/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/mcp/conftest.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | import pytest
4 | from sqlmodel import Session
5 |
6 | from app.models import MCPInstance, MCPInstanceStatus
7 |
8 |
9 | @pytest.fixture
10 | def mock_db():
11 | """Create a mock database session."""
12 | return MagicMock(spec=Session)
13 |
14 |
15 | @pytest.fixture
16 | def mcp_instance():
17 | """Create a sample MCP instance."""
18 | return MCPInstance(
19 | id="test-mcp-id",
20 | name="Test MCP Instance",
21 | description="Test MCP instance for testing",
22 | status=MCPInstanceStatus.ACTIVE,
23 | url="http://localhost:8000",
24 | config={"timeout": 30},
25 | owner_id="test-owner-id",
26 | )
27 |
28 |
29 | @pytest.fixture
30 | def mock_fastmcp_server():
31 | """Create a mock FastMCP server."""
32 | with patch("app.mcp.mcp_server.FastMCPServer") as mock:
33 | server = MagicMock()
34 | server._tool_manager._tools = {}
35 | server.tool.return_value = lambda func: func
36 | mock.return_value = server
37 | yield mock
38 |
39 |
40 | @pytest.fixture
41 | def mock_sse_transport():
42 | """Create a mock SSE transport."""
43 | with patch("app.mcp.mcp_server.SseServerTransport") as mock:
44 | transport = MagicMock()
45 | mock.return_value = transport
46 | yield mock
47 |
48 |
49 | @pytest.fixture
50 | def mock_mcp_server():
51 | """Create a mock MCP server."""
52 | with patch("app.mcp.manager.MCPServer") as mock:
53 | server = MagicMock()
54 | mock.return_value = server
55 | yield mock
56 |
57 |
58 | @pytest.fixture
59 | def mock_schema_to_function():
60 | """Create a mock schema_to_function."""
61 | with patch("app.mcp.openapi.schema_to_func.schema_to_function") as mock:
62 | func = MagicMock()
63 | mock.return_value = func
64 | yield mock
65 |
--------------------------------------------------------------------------------
/backend/app/tests/scripts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/scripts/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/scripts/test_backend_pre_start.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from sqlmodel import select
4 |
5 | from app.backend_pre_start import init, logger
6 |
7 |
8 | def test_init_successful_connection() -> None:
9 | engine_mock = MagicMock()
10 |
11 | session_mock = MagicMock()
12 | exec_mock = MagicMock(return_value=True)
13 | session_mock.configure_mock(**{"exec.return_value": exec_mock})
14 | with patch("sqlmodel.Session", return_value=session_mock), patch.object(
15 | logger, "info"
16 | ), patch.object(logger, "error"), patch.object(logger, "warn"):
17 | try:
18 | init(engine_mock)
19 | connection_successful = True
20 | except Exception:
21 | connection_successful = False
22 |
23 | assert (
24 | connection_successful
25 | ), "The database connection should be successful and not raise an exception."
26 |
27 | assert session_mock.exec.called_once_with(
28 | select(1)
29 | ), "The session should execute a select statement once."
30 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/backend/app/tests/utils/__init__.py
--------------------------------------------------------------------------------
/backend/app/tests/utils/chat.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlmodel import Session
4 |
5 | from app import crud
6 | from app.models import Chat, Message
7 | from app.tests.utils.user import create_random_user
8 | from app.tests.utils.utils import random_lower_string, random_string
9 |
10 |
11 | def create_random_chat(db: Session) -> Chat:
12 | user = create_random_user(db)
13 | user_id = str(user.id)
14 |
15 | chat_id = random_string()
16 | title = random_lower_string()
17 | path = random_lower_string()
18 |
19 | chat_message = Message(
20 | id=random_string(),
21 | role="system",
22 | name="Bot",
23 | parts=[random_lower_string()],
24 | attachments=None,
25 | )
26 |
27 | chat = Chat(
28 | id=chat_id,
29 | title=title,
30 | path=path,
31 | user_id=user_id,
32 | messages=[chat_message],
33 | created_at=datetime.utcnow(),
34 | updated_at=datetime.utcnow(),
35 | )
36 |
37 | return crud.create_chat(session=db, chat=chat)
38 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/item.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Session
2 |
3 | from app import crud
4 | from app.models import Item, ItemCreate
5 | from app.tests.utils.user import create_random_user
6 | from app.tests.utils.utils import random_lower_string
7 |
8 |
9 | def create_random_item(db: Session) -> Item:
10 | user = create_random_user(db)
11 | owner_id = user.id
12 | assert owner_id is not None
13 | title = random_lower_string()
14 | description = random_lower_string()
15 | item_in = ItemCreate(title=title, description=description)
16 | return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
17 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/project.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | from sqlmodel import Session
5 |
6 | from app.models import Project
7 |
8 |
9 | def create_random_project(db: Session, team_id: str) -> Project:
10 | random_suffix = "".join(random.choices(string.ascii_lowercase, k=6))
11 | project = Project(
12 | title=f"Test Project {random_suffix}",
13 | description=f"Test description {random_suffix}",
14 | model="gpt-4",
15 | team_id=team_id,
16 | )
17 | db.add(project)
18 | db.commit()
19 | db.refresh(project)
20 | return project
21 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/secret.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Session
2 |
3 | from app.models.secret import Secret
4 | from app.tests.utils.utils import random_string
5 |
6 |
7 | def create_random_secret(*, session: Session, owner_id: str) -> Secret:
8 | """Create a random secret for testing."""
9 | secret = Secret(
10 | name=f"TEST_SECRET_{random_string()}",
11 | description=f"Test secret description {random_string()}",
12 | value=f"secret-value-{random_string()}",
13 | environment="development",
14 | owner_id=owner_id,
15 | )
16 | session.add(secret)
17 | session.commit()
18 | session.refresh(secret)
19 | return secret
20 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/setting.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | from sqlmodel import Session
4 |
5 | from app import crud
6 | from app.models import Setting, SettingCreate
7 | from app.tests.utils.user import create_random_user
8 | from app.tests.utils.utils import random_lower_string
9 |
10 |
11 | def create_random_setting(db: Session) -> Setting:
12 | user = create_random_user(db)
13 | owner_id = user.id
14 | assert owner_id is not None
15 | name = random_lower_string()
16 | kind = random.choice(["kind1", "kind2", "kind3"])
17 | data = {"key": random_lower_string(), "value": random_lower_string()}
18 | setting_create = SettingCreate(name=name, kind=kind, data=data)
19 | return crud.create_setting(
20 | session=db, setting_create=setting_create, owner_id=owner_id
21 | )
22 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/team.py:
--------------------------------------------------------------------------------
1 | from sqlmodel import Session
2 |
3 | from app.models import (
4 | Team,
5 | TeamCreate,
6 | TeamInvitationStatus,
7 | TeamMember,
8 | TeamRole,
9 | User,
10 | )
11 | from app.tests.utils.user import get_super_user
12 | from app.tests.utils.utils import random_lower_string
13 |
14 |
15 | def create_random_team(db: Session, user: User | None = None) -> Team:
16 | if user is None:
17 | user = get_super_user(db)
18 | owner_id = user.id
19 | name = random_lower_string()
20 | description = random_lower_string()
21 | team_in = TeamCreate(name=name, description=description)
22 | team = Team.model_validate(team_in)
23 | db.add(team)
24 | db.commit()
25 | db.refresh(team)
26 |
27 | # Adding owner_id to TeamMember
28 | team_member = TeamMember(
29 | team_id=team.id,
30 | user_id=owner_id,
31 | role=TeamRole.OWNER,
32 | invitation_status=TeamInvitationStatus.ACCEPTED,
33 | )
34 | db.add(team_member)
35 | db.commit()
36 | db.refresh(team_member)
37 |
38 | return team
39 |
40 |
41 | def add_team_member(
42 | db: Session,
43 | team: Team,
44 | user: User,
45 | role: TeamRole = TeamRole.MEMBER,
46 | invitation_status: TeamInvitationStatus = TeamInvitationStatus.ACCEPTED,
47 | ) -> TeamMember:
48 | team_member = TeamMember(
49 | team_id=team.id,
50 | user_id=user.id,
51 | role=role,
52 | invitation_status=invitation_status,
53 | )
54 | db.add(team_member)
55 | db.commit()
56 | db.refresh(team_member)
57 | return team_member
58 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/tool_call.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | from sqlmodel import Session
4 |
5 | from app.models import ToolCall, ToolCallCreate
6 | from app.tests.utils.user import create_random_user
7 | from app.tests.utils.utils import random_lower_string, random_string
8 |
9 |
10 | def create_random_tool_call(db: Session) -> ToolCall:
11 | user = create_random_user(db)
12 | owner_id = user.id
13 | assert owner_id is not None
14 |
15 | chat_id = random_string()
16 | kind = random.choice(["kind1", "kind2", "kind3"])
17 | result = (
18 | {
19 | "result_key": random_lower_string(),
20 | "result_value": random_lower_string(),
21 | }
22 | if random.random() < 0.5
23 | else None
24 | )
25 | status = random.choice(["pending", "in_progress", "completed", "failed"])
26 | payload = {
27 | "key1": random_lower_string(),
28 | "key2": random_lower_string(),
29 | }
30 |
31 | tool_call_create = ToolCallCreate(
32 | chat_id=chat_id,
33 | kind=kind,
34 | result=result,
35 | status=status,
36 | payload=payload,
37 | )
38 |
39 | tool_call = ToolCall(**tool_call_create.dict(), owner_id=owner_id)
40 | db.add(tool_call)
41 | db.commit()
42 | db.refresh(tool_call)
43 | return tool_call
44 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/user.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 | from sqlmodel import Session
3 |
4 | from app import crud
5 | from app.core.config import settings
6 | from app.models import User, UserCreate, UserUpdate
7 | from app.tests.utils.utils import random_email, random_lower_string
8 |
9 |
10 | def user_authentication_headers(
11 | *, client: TestClient, email: str, password: str
12 | ) -> dict[str, str]:
13 | data = {"username": email, "password": password}
14 |
15 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data)
16 | response = r.json()
17 | auth_token = response["access_token"]
18 | headers = {"Authorization": f"Bearer {auth_token}"}
19 | return headers
20 |
21 |
22 | def create_random_user(db: Session) -> User:
23 | email = random_email()
24 | password = random_lower_string()
25 | user_in = UserCreate(email=email, password=password)
26 | user = crud.create_user(session=db, user_create=user_in)
27 | return user
28 |
29 |
30 | def get_super_user(db: Session) -> User:
31 | user = crud.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER)
32 | return user
33 |
34 |
35 | def authentication_token_from_email(
36 | *, client: TestClient, email: str, db: Session
37 | ) -> dict[str, str]:
38 | """
39 | Return a valid token for the user with given email.
40 |
41 | If the user doesn't exist it is created first.
42 | """
43 | password = random_lower_string()
44 | user = crud.get_user_by_email(session=db, email=email)
45 | if not user:
46 | user_in_create = UserCreate(email=email, password=password)
47 | user = crud.create_user(session=db, user_create=user_in_create)
48 | else:
49 | user_in_update = UserUpdate(password=password)
50 | if not user.id:
51 | raise Exception("User id not set")
52 | user = crud.update_user(session=db, db_user=user, user_in=user_in_update)
53 |
54 | return user_authentication_headers(client=client, email=email, password=password)
55 |
--------------------------------------------------------------------------------
/backend/app/tests/utils/utils.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | import nanoid
5 | from fastapi.testclient import TestClient
6 |
7 | from app.core.config import settings
8 |
9 |
10 | def random_lower_string() -> str:
11 | return "".join(random.choices(string.ascii_lowercase, k=32))
12 |
13 |
14 | def random_string() -> str:
15 | return nanoid.generate()
16 |
17 |
18 | def random_email() -> str:
19 | return f"{random_lower_string()}@{random_lower_string()}.com"
20 |
21 |
22 | def get_superuser_token_headers(client: TestClient) -> dict[str, str]:
23 | login_data = {
24 | "username": settings.FIRST_SUPERUSER,
25 | "password": settings.FIRST_SUPERUSER_PASSWORD,
26 | }
27 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
28 | tokens = r.json()
29 | a_token = tokens["access_token"]
30 | headers = {"Authorization": f"Bearer {a_token}"}
31 | return headers
32 |
--------------------------------------------------------------------------------
/backend/app/tests_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlalchemy import Engine
4 | from sqlmodel import Session, select
5 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
6 |
7 | from app.core.db import engine
8 |
9 | logging.basicConfig(level=logging.INFO)
10 | logger = logging.getLogger(__name__)
11 |
12 | max_tries = 60 * 5 # 5 minutes
13 | wait_seconds = 1
14 |
15 |
16 | @retry(
17 | stop=stop_after_attempt(max_tries),
18 | wait=wait_fixed(wait_seconds),
19 | before=before_log(logger, logging.INFO),
20 | after=after_log(logger, logging.WARN),
21 | )
22 | def init(db_engine: Engine) -> None:
23 | try:
24 | # Try to create session to check if DB is awake
25 | with Session(db_engine) as session:
26 | session.exec(select(1))
27 | except Exception as e:
28 | logger.error(e)
29 | raise e
30 |
31 |
32 | def main() -> None:
33 | logger.info("Initializing service")
34 | init(engine)
35 | logger.info("Service finished initializing")
36 |
37 |
38 | if __name__ == "__main__":
39 | main()
40 |
--------------------------------------------------------------------------------
/backend/app/wait_for_db.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import time
3 |
4 | from sqlalchemy import create_engine
5 |
6 | from app.core.config import settings
7 |
8 | logging.basicConfig(level=logging.INFO)
9 | logger = logging.getLogger(__name__)
10 |
11 | max_tries = 60 # Maximum number of tries
12 | wait_seconds = 1 # Seconds to wait between tries
13 |
14 |
15 | def wait_for_db() -> None:
16 | for i in range(max_tries):
17 | try:
18 | engine = create_engine(settings.SQLALCHEMY_DATABASE_URI)
19 | engine.connect()
20 | logger.info("Database is ready!")
21 | return
22 | except Exception:
23 | logger.info(f"Database not ready, waiting... ({i+1}/{max_tries})")
24 | time.sleep(wait_seconds)
25 | raise Exception("Could not connect to database")
26 |
27 |
28 | if __name__ == "__main__":
29 | wait_for_db()
30 |
--------------------------------------------------------------------------------
/backend/prestart.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env bash
2 |
3 | # Let the DB start
4 | python /app/app/backend_pre_start.py
5 |
6 | # Run migrations
7 | alembic upgrade head
8 |
9 | # Create initial data in DB
10 | python /app/app/initial_data.py
11 |
--------------------------------------------------------------------------------
/backend/scripts/format-imports.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 | set -x
3 |
4 | # Sort imports one per line, so autoflake can remove unused imports
5 | isort --recursive --force-single-line-imports --apply app
6 | sh ./scripts/format.sh
7 |
--------------------------------------------------------------------------------
/backend/scripts/format.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 | set -x
3 |
4 | ruff check app scripts --fix
5 | ruff format app scripts
6 |
--------------------------------------------------------------------------------
/backend/scripts/lint.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 | set -x
5 |
6 | mypy app
7 | ruff check app
8 | ruff format app --check
9 |
--------------------------------------------------------------------------------
/backend/scripts/test.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 | set -x
5 |
6 | coverage run --source=app -m pytest
7 | coverage report --show-missing
8 | coverage html --title "${@-coverage}"
9 |
--------------------------------------------------------------------------------
/backend/tests-start.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env bash
2 | set -e
3 | set -x
4 |
5 | python /app/app/tests_pre_start.py
6 |
7 | bash ./scripts/test.sh "$@"
8 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/demo.gif
--------------------------------------------------------------------------------
/docs/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # deps
2 | /node_modules
3 |
4 | # generated content
5 | .contentlayer
6 | .content-collections
7 | .source
8 |
9 | # test & build
10 | /coverage
11 | /.next/
12 | /out/
13 | /build
14 | *.tsbuildinfo
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 | /.pnp
20 | .pnp.js
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # others
26 | .env*.local
27 | .vercel
28 | next-env.d.ts
29 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # docs
2 |
3 | This is a Next.js application generated with
4 | [Create Fumadocs](https://github.com/fuma-nama/fumadocs).
5 |
6 | Run development server:
7 |
8 | ```bash
9 | npm run dev
10 | # or
11 | pnpm dev
12 | # or
13 | yarn dev
14 | ```
15 |
16 | Open http://localhost:3000 with your browser to see the result.
17 |
18 | ## Learn More
19 |
20 | To learn more about Next.js and Fumadocs, take a look at the following
21 | resources:
22 |
23 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
24 | features and API.
25 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
26 | - [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
27 |
--------------------------------------------------------------------------------
/docs/app/(docs)/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import {
3 | DocsPage,
4 | DocsBody,
5 | DocsDescription,
6 | DocsTitle,
7 | } from 'fumadocs-ui/page';
8 | import { notFound } from 'next/navigation';
9 | import { createRelativeLink } from 'fumadocs-ui/mdx';
10 | import { getMDXComponents } from '@/mdx-components';
11 |
12 | export default async function Page(props: {
13 | params: Promise<{ slug?: string[] }>;
14 | }) {
15 | const params = await props.params;
16 | const page = source.getPage(params.slug);
17 | if (!page) notFound();
18 |
19 | const MDXContent = page.data.body;
20 |
21 | return (
22 |
23 | {page.data.title}
24 | {page.data.description}
25 |
26 |
32 |
33 |
34 | );
35 | }
36 |
37 | export async function generateStaticParams() {
38 | return source.generateParams();
39 | }
40 |
41 | export async function generateMetadata(props: {
42 | params: Promise<{ slug?: string[] }>;
43 | }) {
44 | const params = await props.params;
45 | const page = source.getPage(params.slug);
46 | if (!page) notFound();
47 |
48 | return {
49 | title: page.data.title,
50 | description: page.data.description,
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/docs/app/(docs)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { DocsLayout } from 'fumadocs-ui/layouts/docs';
2 | import type { ReactNode } from 'react';
3 | import { baseOptions } from '@/app/layout.config';
4 | import { source } from '@/lib/source';
5 |
6 | export default function Layout({ children }: { children: ReactNode }) {
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/docs/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import { createFromSource } from 'fumadocs-core/search/server';
3 |
4 | export const revalidate = false;
5 | export const { staticGET: GET } = createFromSource(source);
6 |
--------------------------------------------------------------------------------
/docs/app/home/layout.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react"
2 | import "@/app/global.css"
3 | import { Inter } from "next/font/google"
4 | import { ThemeProvider } from "next-themes"
5 |
6 | const inter = Inter({ subsets: ["latin"] })
7 |
8 | export const metadata = {
9 | title: "Centroid - Self-Hostable Open Source Deployment Platform",
10 | description:
11 | "Centroid is a powerful, self-hostable platform that makes deploying and managing your applications simple, secure, and scalable.",
12 | openGraph: {
13 | title: "Centroid - Self-Hostable Open Source Deployment Platform",
14 | description: "Centroid is a powerful, self-hostable platform that makes deploying and managing your applications simple, secure, and scalable.",
15 | type: "website",
16 | },
17 | twitter: {
18 | card: "summary_large_image",
19 | title: "Centroid - Self-Hostable Open Source Deployment Platform",
20 | description: "Centroid is a powerful, self-hostable platform that makes deploying and managing your applications simple, secure, and scalable.",
21 | },
22 | }
23 |
24 | export default function RootLayout({
25 | children,
26 | }: {
27 | children: React.ReactNode
28 | }) {
29 | return (
30 |
31 |
32 |
38 | {children}
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/docs/app/layout.config.tsx:
--------------------------------------------------------------------------------
1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
2 | import { CircleDot } from "lucide-react";
3 |
4 | /**
5 | * Shared layout configurations
6 | *
7 | * you can customise layouts individually from:
8 | * Home Layout: app/(home)/layout.tsx
9 | * Docs Layout: app/docs/layout.tsx
10 | */
11 | export const baseOptions: BaseLayoutProps = {
12 | nav: {
13 | title: (
14 | <>
15 |
16 | Centroid
17 | >
18 | ),
19 | },
20 | links: [
21 | {
22 | text: "Documentation",
23 | url: "/",
24 | active: "nested-url",
25 | },
26 | ],
27 | };
28 |
--------------------------------------------------------------------------------
/docs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./global.css";
2 | import { RootProvider } from "fumadocs-ui/provider";
3 | import { Inter } from "next/font/google";
4 | import type { ReactNode } from "react";
5 |
6 | const inter = Inter({
7 | subsets: ["latin"],
8 | });
9 |
10 | export default function Layout({ children }: { children: ReactNode }) {
11 | return (
12 |
13 |
14 |
15 | {children}
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/docs/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/global.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 | }
22 |
--------------------------------------------------------------------------------
/docs/components/github-stats.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { Star, GitFork, Users } from 'lucide-react'
3 |
4 | interface GitHubStats {
5 | stargazers_count: number
6 | forks_count: number
7 | subscribers_count: number
8 | }
9 |
10 | const fetcher = (url: string) => fetch(url).then((res) => res.json())
11 |
12 | function formatNumber(num: number): string {
13 | if (num >= 1000) {
14 | return `${(num / 1000).toFixed(1)}k`
15 | }
16 | return num.toString()
17 | }
18 |
19 | export function GitHubStats() {
20 | const { data, error, isLoading } = useSWR(
21 | 'https://api.github.com/repos/NeuclaiLabs/centroid',
22 | fetcher,
23 | {
24 | revalidateOnFocus: false,
25 | revalidateOnReconnect: false
26 | }
27 | )
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | {isLoading ? '...' : error ? '0' : formatNumber(data?.stargazers_count || 0)}
36 |
37 |
GitHub Stars
38 |
39 |
40 |
41 |
42 |
43 |
44 | {isLoading ? '...' : error ? '0' : formatNumber(data?.forks_count || 0)}
45 |
46 |
Forks
47 |
48 |
49 |
50 |
51 |
52 |
53 | {isLoading ? '...' : error ? '0' : formatNumber(data?.subscribers_count || 0)}
54 |
55 |
Watchers
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/docs/components/mobile-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from "react"
4 | import Link from "next/link"
5 | import { Menu } from "lucide-react"
6 |
7 | import { Button } from "@/components/ui/button"
8 | import {
9 | Sheet,
10 | SheetContent,
11 | SheetHeader,
12 | SheetTitle,
13 | SheetTrigger,
14 | } from "@/components/ui/sheet"
15 |
16 | export function MobileNav() {
17 | const [open, setOpen] = React.useState(false)
18 |
19 | return (
20 |
21 |
22 |
23 |
24 | Toggle menu
25 |
26 |
27 |
28 |
29 | Navigation
30 |
31 |
32 | setOpen(false)}
36 | >
37 | Features
38 |
39 | setOpen(false)}
43 | >
44 | How It Works
45 |
46 | setOpen(false)}
50 | >
51 | Community
52 |
53 | setOpen(false)}
57 | >
58 | Documentation
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/docs/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Moon, Sun } from "lucide-react"
4 | import { useTheme } from "next-themes"
5 |
6 | import { Button } from "@/components/ui/button"
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 |
14 | export function ThemeToggle() {
15 | const { setTheme } = useTheme()
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | Toggle theme
24 |
25 |
26 |
27 | setTheme("light")}>
28 | Light
29 |
30 | setTheme("dark")}>
31 | Dark
32 |
33 | setTheme("system")}>
34 | System
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/docs/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"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/docs/content/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Hello World
3 | description: Your first document
4 | ---
5 |
6 | Welcome to the docs! You can start writing documents in `/content/docs`.
7 |
8 | ## What is Next?
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/docs/content/docs/test.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Components
3 | description: Components
4 | ---
5 |
6 | ## Code Block
7 |
8 | ```js
9 | console.log('Hello World');
10 | ```
11 |
12 | ## Cards
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/lib/source.ts:
--------------------------------------------------------------------------------
1 | import { docs } from '@/.source';
2 | import { loader } from 'fumadocs-core/source';
3 |
4 | // See https://fumadocs.vercel.app/docs/headless/source-api for more info
5 | export const source = loader({
6 | // it assigns a URL to your pages
7 | baseUrl: '/',
8 | source: docs.toFumadocsSource(),
9 | });
10 |
--------------------------------------------------------------------------------
/docs/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/docs/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import defaultMdxComponents from 'fumadocs-ui/mdx';
2 | import type { MDXComponents } from 'mdx/types';
3 |
4 | // use this function to get MDX components, you will need it for rendering MDX
5 | export function getMDXComponents(components?: MDXComponents): MDXComponents {
6 | return {
7 | ...defaultMdxComponents,
8 | ...components,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { createMDX } from 'fumadocs-mdx/next';
2 |
3 | const withMDX = createMDX();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const config = {
7 | reactStrictMode: true,
8 | output: 'export',
9 | images: {
10 | unoptimized: true,
11 | },
12 | trailingSlash: true,
13 | // Optimize build output size
14 | webpack: (config, { isServer }) => {
15 | if (!isServer) {
16 | config.optimization = {
17 | ...config.optimization,
18 | splitChunks: {
19 | chunks: 'all',
20 | minSize: 20000,
21 | maxSize: 24000000, // Keep chunks under 24MB
22 | cacheGroups: {
23 | default: false,
24 | vendors: false,
25 | commons: {
26 | name: 'commons',
27 | chunks: 'all',
28 | minChunks: 2,
29 | },
30 | },
31 | },
32 | }
33 | }
34 | return config
35 | }
36 | };
37 |
38 | export default withMDX(config);
39 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev --turbo",
8 | "start": "next start",
9 | "postinstall": "fumadocs-mdx"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-dialog": "^1.1.11",
13 | "@radix-ui/react-dropdown-menu": "^2.1.12",
14 | "@radix-ui/react-slot": "^1.2.0",
15 | "class-variance-authority": "^0.7.1",
16 | "clsx": "^2.1.1",
17 | "fumadocs-core": "15.2.7",
18 | "fumadocs-mdx": "11.6.0",
19 | "fumadocs-ui": "15.2.7",
20 | "lucide-react": "^0.503.0",
21 | "next": "15.3.0",
22 | "next-themes": "^0.4.6",
23 | "react": "^19.1.0",
24 | "react-dom": "^19.1.0",
25 | "swr": "^2.3.3",
26 | "tailwind-merge": "^3.2.0"
27 | },
28 | "devDependencies": {
29 | "@tailwindcss/postcss": "^4.1.3",
30 | "@types/mdx": "^2.0.13",
31 | "@types/node": "22.14.0",
32 | "@types/react": "^19.1.0",
33 | "@types/react-dom": "^19.1.2",
34 | "eslint": "^8",
35 | "eslint-config-next": "15.3.0",
36 | "postcss": "^8.5.3",
37 | "tailwindcss": "^4.1.3",
38 | "tw-animate-css": "^1.2.8",
39 | "typescript": "^5.8.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/docs/source.config.ts:
--------------------------------------------------------------------------------
1 | import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
2 |
3 | // Options: https://fumadocs.vercel.app/docs/mdx/collections#define-docs
4 | export const docs = defineDocs({
5 | dir: 'content/docs',
6 | });
7 |
8 | export default defineConfig({
9 | mdxOptions: {
10 | // MDX options
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "paths": {
19 | "@/.source": ["./.source/index.ts"],
20 | "@/*": ["./*"]
21 | },
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
29 | "exclude": ["node_modules"]
30 | }
31 |
--------------------------------------------------------------------------------
/docs/wrangler.toml:
--------------------------------------------------------------------------------
1 | # https://developers.cloudflare.com/pages/configuration/build-configuration/
2 | name = "centroid"
3 | compatibility_date = "2024-03-20"
4 | compatibility_flags = ["nodejs_compat"]
5 | pages_build_output_dir = "out"
6 |
7 | [env.production]
8 | name = "centroid-prod"
9 |
--------------------------------------------------------------------------------
/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | cd /app/backend
4 |
5 | # Set up ChromaDB model persistence
6 | echo "[entrypoint.sh] Setting up ChromaDB model persistence..."
7 | CACHE_DIR="/root/.cache/chroma/onnx_models"
8 | PERSISTENT_DIR="/app/data/.chromadb/models"
9 |
10 | # Create persistent directory if it doesn't exist
11 | mkdir -p "$PERSISTENT_DIR"
12 |
13 | # Remove existing cache directory or symlink if it exists
14 | rm -rf "$CACHE_DIR"
15 |
16 | # Create parent directory for cache
17 | mkdir -p "$(dirname "$CACHE_DIR")"
18 |
19 | # Create symlink
20 | ln -s "$PERSISTENT_DIR" "$CACHE_DIR"
21 |
22 | # Wait for database to be ready
23 | echo "[entrypoint.sh] Waiting for database..."
24 | poetry run python app/wait_for_db.py
25 |
26 | # Run migrations
27 | echo "[entrypoint.sh] Running database migrations..."
28 | poetry run alembic upgrade head
29 |
30 | # Run the seeding script
31 | echo "[entrypoint.sh] Running initial data script..."
32 | poetry run python app/initial_data.py
33 |
34 | # Change back to root directory before executing start.sh
35 | echo "[entrypoint.sh] Changing back to root directory..."
36 | cd /app
37 |
38 | # Start the application
39 | echo "[entrypoint.sh] Executing start.sh with args: $@"
40 | exec "$@"
41 |
--------------------------------------------------------------------------------
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
2 | AUTH_SECRET=****
3 |
4 | # The following keys below are automatically created and
5 | # added to your environment when you deploy on vercel
6 |
7 | # Get your xAI API Key here for chat and image models: https://console.x.ai/
8 | XAI_API_KEY=****
9 |
10 | # Get your Groq API Key here for reasoning models: https://console.groq.com/keys
11 | GROQ_API_KEY=****
12 |
13 | # Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob
14 | BLOB_READ_WRITE_TOKEN=****
15 |
16 | # Instructions to create a database here: https://vercel.com/docs/storage/vercel-postgres/quickstart
17 | POSTGRES_URL=****
18 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "plugin:import/recommended",
5 | "plugin:import/typescript",
6 | "prettier",
7 | "plugin:tailwindcss/recommended"
8 | ],
9 | "plugins": ["tailwindcss"],
10 | "rules": {
11 | "tailwindcss/no-custom-classname": "off",
12 | "tailwindcss/classnames-order": "off"
13 | },
14 | "settings": {
15 | "import/resolver": {
16 | "typescript": {
17 | "alwaysTryTypes": true
18 | }
19 | }
20 | },
21 | "ignorePatterns": ["components/**", "app/**", "lib/**"]
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on:
3 | push:
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-22.04
8 | strategy:
9 | matrix:
10 | node-version: [20]
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Install pnpm
14 | uses: pnpm/action-setup@v4
15 | with:
16 | version: 9.12.3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'pnpm'
22 | - name: Install dependencies
23 | run: pnpm install
24 | - name: Run lint
25 | run: pnpm lint
--------------------------------------------------------------------------------
/frontend/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [main, master]
5 | pull_request:
6 | branches: [main, master]
7 |
8 | jobs:
9 | test:
10 | timeout-minutes: 30
11 | runs-on: ubuntu-latest
12 | env:
13 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
14 | POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
15 | BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | with:
20 | fetch-depth: 1
21 |
22 | - uses: actions/setup-node@v4
23 | with:
24 | node-version: lts/*
25 |
26 | - name: Install pnpm
27 | uses: pnpm/action-setup@v2
28 | with:
29 | version: latest
30 | run_install: false
31 |
32 | - name: Get pnpm store directory
33 | id: pnpm-cache
34 | shell: bash
35 | run: |
36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
37 |
38 | - uses: actions/cache@v3
39 | with:
40 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
41 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
42 | restore-keys: |
43 | ${{ runner.os }}-pnpm-store-
44 |
45 | - uses: actions/setup-node@v4
46 | with:
47 | node-version: lts/*
48 | cache: "pnpm"
49 |
50 | - name: Install dependencies
51 | run: pnpm install --frozen-lockfile
52 |
53 | - name: Cache Playwright browsers
54 | uses: actions/cache@v3
55 | id: playwright-cache
56 | with:
57 | path: ~/.cache/ms-playwright
58 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
59 |
60 | - name: Install Playwright Browsers
61 | if: steps.playwright-cache.outputs.cache-hit != 'true'
62 | run: pnpm exec playwright install --with-deps chromium
63 |
64 | - name: Run Playwright tests
65 | run: pnpm test
66 |
67 | - uses: actions/upload-artifact@v4
68 | if: always() && !cancelled()
69 | with:
70 | name: playwright-report
71 | path: playwright-report/
72 | retention-days: 7
73 |
--------------------------------------------------------------------------------
/frontend/.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 |
8 | # testing
9 | coverage
10 |
11 | # next.js
12 | .next/
13 | out/
14 | build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .pnpm-debug.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | # turbo
33 | .turbo
34 |
35 | .env
36 | .vercel
37 | .env*.local
38 |
39 | # Playwright
40 | /test-results/
41 | /playwright-report/
42 | /blob-report/
43 | /playwright/*
44 |
--------------------------------------------------------------------------------
/frontend/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["biomejs.biome"]
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "biomejs.biome"
5 | },
6 | "[typescript]": {
7 | "editor.defaultFormatter": "biomejs.biome"
8 | },
9 | "[typescriptreact]": {
10 | "editor.defaultFormatter": "biomejs.biome"
11 | },
12 | "typescript.tsdk": "node_modules/typescript/lib",
13 | "eslint.workingDirectories": [
14 | { "pattern": "app/*" },
15 | { "pattern": "packages/*" }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Vercel, Inc.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/frontend/app/(auth)/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | export { GET, POST } from '@/app/(auth)/auth';
2 |
--------------------------------------------------------------------------------
/frontend/app/(auth)/auth.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextAuthConfig } from 'next-auth';
2 |
3 | export const authConfig = {
4 | pages: {
5 | signIn: '/login',
6 | newUser: '/',
7 | },
8 | providers: [
9 | // added later in auth.ts since it requires bcrypt which is only compatible with Node.js
10 | // while this file is also used in non-Node.js environments
11 | ],
12 | callbacks: {
13 | authorized({ auth, request: { nextUrl } }) {
14 | const isLoggedIn = !!auth?.user;
15 | const isOnChat = nextUrl.pathname.startsWith('/');
16 | const isOnRegister = nextUrl.pathname.startsWith('/register');
17 | const isOnLogin = nextUrl.pathname.startsWith('/login');
18 |
19 | if (isLoggedIn && (isOnLogin || isOnRegister)) {
20 | return Response.redirect(new URL('/', nextUrl as unknown as URL));
21 | }
22 |
23 | if (isOnRegister || isOnLogin) {
24 | return true; // Always allow access to register and login pages
25 | }
26 |
27 | if (isOnChat) {
28 | if (isLoggedIn) return true;
29 | return false; // Redirect unauthenticated users to login page
30 | }
31 |
32 | if (isLoggedIn) {
33 | return Response.redirect(new URL('/', nextUrl as unknown as URL));
34 | }
35 |
36 | return true;
37 | },
38 | },
39 | } satisfies NextAuthConfig;
40 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { generateText, type Message } from 'ai';
4 | import { cookies } from 'next/headers';
5 |
6 | import {
7 | deleteMessagesByChatIdAfterTimestamp,
8 | getMessageById,
9 | updateChatVisiblityById,
10 | } from '@/lib/db/queries';
11 | import type { VisibilityType } from '@/components/visibility-selector';
12 | import { myProvider } from '@/lib/ai/providers';
13 |
14 | export async function saveChatModelAsCookie(model: string) {
15 | const cookieStore = await cookies();
16 | cookieStore.set('chat-model', model);
17 | }
18 | 2
19 | export async function generateTitleFromUserMessage({
20 | message,
21 | }: {
22 | message: Message;
23 | }) {
24 | const { text: title } = await generateText({
25 | model: myProvider.languageModel('title-model'),
26 | system: `\n
27 | - you will generate a short title based on the first message a user begins a conversation with
28 | - ensure it is not more than 80 characters long
29 | - the title should be a summary of the user's message
30 | - do not use quotes or colons`,
31 | prompt: JSON.stringify(message),
32 | });
33 |
34 | return title;
35 | }
36 |
37 | export async function deleteTrailingMessages({ id }: { id: string }) {
38 | const message = await getMessageById({ id });
39 |
40 | await deleteMessagesByChatIdAfterTimestamp({
41 | chatId: message.chatId,
42 | timestamp: message.createdAt,
43 | });
44 | }
45 |
46 | export async function updateChatVisibility({
47 | chatId,
48 | visibility,
49 | }: {
50 | chatId: string;
51 | visibility: VisibilityType;
52 | }) {
53 | await updateChatVisiblityById({ chatId, visibility });
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/api/files/upload/route.ts:
--------------------------------------------------------------------------------
1 | import { put } from '@vercel/blob';
2 | import { NextResponse } from 'next/server';
3 | import { z } from 'zod';
4 |
5 | import { auth } from '@/app/(auth)/auth';
6 |
7 | // Use Blob instead of File since File is not available in Node.js environment
8 | const FileSchema = z.object({
9 | file: z
10 | .instanceof(Blob)
11 | .refine((file) => file.size <= 5 * 1024 * 1024, {
12 | message: 'File size should be less than 5MB',
13 | })
14 | // Update the file type based on the kind of files you want to accept
15 | .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), {
16 | message: 'File type should be JPEG or PNG',
17 | }),
18 | });
19 |
20 | export async function POST(request: Request) {
21 | const session = await auth();
22 |
23 | if (!session) {
24 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
25 | }
26 |
27 | if (request.body === null) {
28 | return new Response('Request body is empty', { status: 400 });
29 | }
30 |
31 | try {
32 | const formData = await request.formData();
33 | const file = formData.get('file') as Blob;
34 |
35 | if (!file) {
36 | return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
37 | }
38 |
39 | const validatedFile = FileSchema.safeParse({ file });
40 |
41 | if (!validatedFile.success) {
42 | const errorMessage = validatedFile.error.errors
43 | .map((error) => error.message)
44 | .join(', ');
45 |
46 | return NextResponse.json({ error: errorMessage }, { status: 400 });
47 | }
48 |
49 | // Get filename from formData since Blob doesn't have name property
50 | const filename = (formData.get('file') as File).name;
51 | const fileBuffer = await file.arrayBuffer();
52 |
53 | try {
54 | const data = await put(`${filename}`, fileBuffer, {
55 | access: 'public',
56 | });
57 |
58 | return NextResponse.json(data);
59 | } catch (error) {
60 | return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
61 | }
62 | } catch (error) {
63 | return NextResponse.json(
64 | { error: 'Failed to process request' },
65 | { status: 500 },
66 | );
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/api/history/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import { getChatsByUserId } from '@/lib/db/queries';
3 |
4 | export async function GET() {
5 | const session = await auth();
6 |
7 | if (!session || !session.user) {
8 | return Response.json('Unauthorized!', { status: 401 });
9 | }
10 |
11 | // biome-ignore lint: Forbidden non-null assertion.
12 | const chats = await getChatsByUserId({ id: session.user.id! });
13 | return Response.json(chats);
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/api/suggestions/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import { getSuggestionsByDocumentId } from '@/lib/db/queries';
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams } = new URL(request.url);
6 | const documentId = searchParams.get('documentId');
7 |
8 | if (!documentId) {
9 | return new Response('Not Found', { status: 404 });
10 | }
11 |
12 | const session = await auth();
13 |
14 | if (!session || !session.user) {
15 | return new Response('Unauthorized', { status: 401 });
16 | }
17 |
18 | const suggestions = await getSuggestionsByDocumentId({
19 | documentId,
20 | });
21 |
22 | const [suggestion] = suggestions;
23 |
24 | if (!suggestion) {
25 | return Response.json([], { status: 200 });
26 | }
27 |
28 | if (suggestion.userId !== session.user.id) {
29 | return new Response('Unauthorized', { status: 401 });
30 | }
31 |
32 | return Response.json(suggestions, { status: 200 });
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/api/vote/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries';
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams } = new URL(request.url);
6 | const chatId = searchParams.get('chatId');
7 |
8 | if (!chatId) {
9 | return new Response('chatId is required', { status: 400 });
10 | }
11 |
12 | const session = await auth();
13 |
14 | if (!session || !session.user || !session.user.email) {
15 | return new Response('Unauthorized', { status: 401 });
16 | }
17 |
18 | const chat = await getChatById({ id: chatId });
19 |
20 | if (!chat) {
21 | return new Response('Chat not found', { status: 404 });
22 | }
23 |
24 | if (chat.userId !== session.user.id) {
25 | return new Response('Unauthorized', { status: 401 });
26 | }
27 |
28 | const votes = await getVotesByChatId({ id: chatId });
29 |
30 | return Response.json(votes, { status: 200 });
31 | }
32 |
33 | export async function PATCH(request: Request) {
34 | const {
35 | chatId,
36 | messageId,
37 | type,
38 | }: { chatId: string; messageId: string; type: 'up' | 'down' } =
39 | await request.json();
40 |
41 | if (!chatId || !messageId || !type) {
42 | return new Response('messageId and type are required', { status: 400 });
43 | }
44 |
45 | const session = await auth();
46 |
47 | if (!session || !session.user || !session.user.email) {
48 | return new Response('Unauthorized', { status: 401 });
49 | }
50 |
51 | const chat = await getChatById({ id: chatId });
52 |
53 | if (!chat) {
54 | return new Response('Chat not found', { status: 404 });
55 | }
56 |
57 | if (chat.userId !== session.user.id) {
58 | return new Response('Unauthorized', { status: 401 });
59 | }
60 |
61 | await voteMessage({
62 | chatId,
63 | messageId,
64 | type: type,
65 | });
66 |
67 | return new Response('Message voted', { status: 200 });
68 | }
69 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 |
3 | import { AppSidebar } from '@/components/app-sidebar';
4 | import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
5 | import { auth } from '../(auth)/auth';
6 | import Script from 'next/script';
7 |
8 | export const experimental_ppr = true;
9 |
10 | export default async function Layout({
11 | children,
12 | }: {
13 | children: React.ReactNode;
14 | }) {
15 | const [session, cookieStore] = await Promise.all([auth(), await cookies()]);
16 | const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true';
17 |
18 | return (
19 | <>
20 |
24 |
25 |
26 | {children}
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/frontend/app/(chat)/opengraph-image.png
--------------------------------------------------------------------------------
/frontend/app/(chat)/page.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 |
3 | import { Chat } from '@/components/chat';
4 | import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
5 | import { generateUUID } from '@/lib/utils';
6 | import { DataStreamHandler } from '@/components/data-stream-handler';
7 |
8 | export default async function Page() {
9 | const id = generateUUID();
10 |
11 | const cookieStore = await cookies();
12 | const modelIdFromCookie = cookieStore.get('chat-model');
13 |
14 | if (!modelIdFromCookie) {
15 | return (
16 | <>
17 |
25 |
26 | >
27 | );
28 | }
29 |
30 | return (
31 | <>
32 |
40 |
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/app/(chat)/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/frontend/app/(chat)/twitter-image.png
--------------------------------------------------------------------------------
/frontend/app/(core)/api/mcp/servers/[id]/[action]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import type { NextRequest } from 'next/server';
3 | import { auth } from '@/app/(auth)/auth';
4 |
5 | export async function POST(
6 | request: NextRequest,
7 | props: { params: Promise<{ id: string; action: string }> },
8 | ) {
9 | const params = await props.params;
10 | try {
11 | const session = await auth();
12 | if (!session) {
13 | return new Response('Unauthorized', { status: 401 });
14 | }
15 |
16 | const { id, action } = params;
17 | const response = await fetch(
18 | `${process.env.NEXT_PUBLIC_API_URL}/api/v1/mcp/servers/${id}/${action}`,
19 | {
20 | method: 'POST',
21 | headers: {
22 | accept: 'application/json',
23 | 'Content-Type': 'application/json',
24 | // @ts-ignore
25 | Authorization: `Bearer ${session.user.token}`,
26 | },
27 | },
28 | );
29 |
30 | if (!response.ok) {
31 | const errorData = await response.json().catch(() => ({}));
32 | return NextResponse.json(
33 | { error: errorData.error || 'Failed to perform action on MCP server' },
34 | { status: response.status },
35 | );
36 | }
37 |
38 | const data = await response.json();
39 | return NextResponse.json(data);
40 | } catch (error) {
41 | console.error('Failed to perform action on MCP server:', error);
42 | return NextResponse.json(
43 | { error: 'Failed to perform action on MCP server' },
44 | { status: 500 },
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/app-header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ArrowRight, Pencil } from "lucide-react";
3 | import { appRegistry } from "@/lib/registry";
4 | import { useConnections } from "../hooks/use-connections";
5 |
6 | interface AppHeaderProps {
7 | appId: string;
8 | onConnect: (connectionId?: string) => void;
9 | }
10 |
11 | export function AppHeader({ appId, onConnect }: AppHeaderProps) {
12 | const appData = appRegistry[appId];
13 | const { connections, isLoading } = useConnections({ appId, limit: 1 });
14 | const existingConnection = connections?.[0];
15 |
16 | return (
17 |
18 |
29 |
30 |
31 |
32 |
33 | {appData.name}
34 |
35 |
36 | {appData.description}
37 |
38 |
39 |
onConnect(undefined)}
43 | disabled={isLoading}
44 | >
45 | Create Connection
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/create-connection-card.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardHeader,
4 | CardContent,
5 | CardFooter,
6 | } from "@/components/ui/card";
7 | import { Plus } from "lucide-react";
8 | import { useRouter } from "next/navigation";
9 |
10 | export function CreateConnectionCard() {
11 | const router = useRouter();
12 |
13 | const handleCardClick = () => {
14 | router.push(`/tools/new`);
15 | };
16 |
17 | return (
18 |
22 |
23 |
24 |
25 |
28 |
29 | Create New
30 | Connection
31 |
32 |
33 |
34 |
35 |
36 |
37 | Create a new connection to integrate with your tools and services.
38 |
39 |
40 |
41 |
42 |
43 | Configure a new tool connection
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/mcp-server-connection-modal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogHeader,
5 | DialogTitle,
6 | } from "@/components/ui/dialog";
7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
8 | import type { MCPServer } from "@/app/(core)/types";
9 |
10 | interface MCPServerConnectionModalProps {
11 | isOpen: boolean;
12 | onOpenChange: (open: boolean) => void;
13 | server: MCPServer;
14 | }
15 |
16 | export function MCPServerConnectionModal({
17 | isOpen,
18 | onOpenChange,
19 | server,
20 | }: MCPServerConnectionModalProps) {
21 | return (
22 |
23 |
24 |
25 | Connect to {server.name}
26 |
27 |
28 |
29 | Cursor
30 | GitHub Copilot
31 |
32 |
33 |
34 | {JSON.stringify({ url: server.mountPath }, null, 2)}
35 |
36 |
37 |
38 |
39 | {JSON.stringify({ url: server.mountPath }, null, 2)}
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/schema-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Copy } from "lucide-react";
3 | import { toast } from "sonner";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogHeader,
9 | DialogTitle,
10 | } from "@/components/ui/dialog";
11 | import type { MCPTool } from "@/app/(core)/types";
12 |
13 | interface SchemaDialogProps {
14 | isOpen: boolean;
15 | onOpenChange: (open: boolean) => void;
16 | tool: MCPTool | null;
17 | }
18 |
19 | export function SchemaDialog({
20 | isOpen,
21 | onOpenChange,
22 | tool,
23 | }: SchemaDialogProps) {
24 | return (
25 | {
28 | if (!open) {
29 | onOpenChange(false);
30 | }
31 | }}
32 | >
33 |
34 |
35 |
36 | {String(tool?.name || tool?.id)}
37 | {
42 | const schema = tool?.parameters;
43 | if (schema) {
44 | navigator.clipboard.writeText(
45 | JSON.stringify(schema, null, 2),
46 | );
47 | toast.success("Schema copied to clipboard");
48 | }
49 | }}
50 | >
51 |
52 |
53 |
54 |
55 | {String(tool?.description || "No description available")}
56 |
57 |
58 |
59 |
68 |
69 | {JSON.stringify(tool || null, null, 2)}
70 |
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/secrets-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { Key, Plus } from "lucide-react";
4 | import { ScrollArea } from "@/components/ui/scroll-area";
5 |
6 | interface SecretsTabProps {
7 | templateId: string;
8 | }
9 |
10 | export function SecretsTab({ templateId }: SecretsTabProps) {
11 | const template = getMCPTemplateById(templateId);
12 | if (!template) return null;
13 |
14 | // Extract environment variables from run config
15 | const envVars = template.run.env || {};
16 |
17 | return (
18 |
19 |
20 |
21 |
Environment Variables
22 |
23 |
24 | Add Secret
25 |
26 |
27 |
28 |
29 |
30 | Required Secrets
31 |
32 |
33 |
34 | {Object.entries(envVars).map(([key, value]) => (
35 |
39 |
40 |
41 | {key}
42 |
43 |
44 |
45 | {value.startsWith("${") && value.endsWith("}")
46 | ? "Not configured"
47 | : "Configured"}
48 |
49 |
50 | Edit
51 |
52 |
53 |
54 | ))}
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/states.tsx:
--------------------------------------------------------------------------------
1 | import { AlertCircle, Loader2 } from "lucide-react";
2 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3 |
4 | export function LoadingState() {
5 | return (
6 |
7 |
8 |
9 |
Loading connections...
10 |
11 |
12 | );
13 | }
14 |
15 | export function ErrorState() {
16 | return (
17 |
18 |
19 |
20 | Error
21 |
22 | Failed to load connections. Please try again later.
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/app/(core)/components/tools-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { Code2, FileText } from "lucide-react";
4 | import { ScrollArea } from "@/components/ui/scroll-area";
5 | import type { MCPTool } from "@/app/(core)/types";
6 |
7 | interface ToolsTabProps {
8 | templateId: string;
9 | onOpenSchema: (tool: MCPTool) => void;
10 | }
11 |
12 | export function ToolsTab({ templateId, onOpenSchema }: ToolsTabProps) {
13 | const template = getMCPTemplateById(templateId);
14 | if (!template) return null;
15 |
16 | return (
17 |
18 |
19 | {template.tools.map((tool) => (
20 |
21 |
22 |
23 |
24 |
25 | {tool.name}
26 |
27 |
28 | {tool.description}
29 |
30 |
31 | onOpenSchema(tool)}
35 | >
36 |
37 | View Schema
38 |
39 |
40 |
41 | ))}
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/app/(core)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 |
3 | import { AppSidebar } from "@/components/app-sidebar";
4 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
5 | import { auth } from "../(auth)/auth";
6 | import { ConnectionHeader } from "@/components/connection-header";
7 |
8 | export const experimental_ppr = true;
9 |
10 | export default async function Layout({
11 | children,
12 | }: {
13 | children: React.ReactNode;
14 | }) {
15 | const [session, cookieStore] = await Promise.all([auth(), await cookies()]);
16 | const isCollapsed = cookieStore.get("sidebar:state")?.value !== "true";
17 |
18 | return (
19 |
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/app/(core)/logs/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Suspense } from "react";
4 | import { LogViewer } from "@/app/(core)/components/log-viewer";
5 | import { Loader2 } from "lucide-react";
6 | import { Card, CardContent } from "@/components/ui/card";
7 |
8 | // Simple loader component
9 | function LogsLoader() {
10 | return (
11 |
12 |
13 |
14 |
Loading logs...
15 |
16 |
17 | );
18 | }
19 |
20 | export default function LogsPage() {
21 | return (
22 |
23 |
24 |
Application Logs
25 |
26 | Monitor and analyze system activity with user-friendly controls
27 |
28 |
29 |
30 |
}>
31 |
32 |
33 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/app/(core)/utils.ts:
--------------------------------------------------------------------------------
1 | import type { ToolDefinition, ToolInstance } from './types';
2 |
3 | export const fetcher = async (url: string) => {
4 | const res = await fetch(url);
5 | if (!res.ok) {
6 | throw new Error('Failed to fetch data');
7 | }
8 | return res.json();
9 | };
10 |
11 | export function filterTools(
12 | tools: ToolDefinition[],
13 | searchQuery: string,
14 | ): ToolDefinition[] {
15 | const query = searchQuery.toLowerCase().trim();
16 | if (!query) return tools;
17 |
18 | return tools.filter((tool) => {
19 | const name = String(tool.toolMetadata?.name || tool.id).toLowerCase();
20 | const description = String(
21 | tool.toolMetadata?.description || '',
22 | ).toLowerCase();
23 | return name.includes(query) || description.includes(query);
24 | });
25 | }
26 |
27 | export function filterToolInstances(
28 | tools: ToolInstance[],
29 | searchQuery: string,
30 | ): ToolInstance[] {
31 | const query = searchQuery.toLowerCase().trim();
32 | if (!query) return tools;
33 |
34 | return tools.filter((tool) => {
35 | const name = String(
36 | tool.definition.toolSchema?.name || tool.id,
37 | ).toLowerCase();
38 | const description = String(
39 | tool.definition.toolSchema?.description || '',
40 | ).toLowerCase();
41 | return name.includes(query) || description.includes(query);
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/frontend/app/favicon.ico
--------------------------------------------------------------------------------
/frontend/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default function HomePage() {
4 | redirect("/mcp/templates");
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/artifacts/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { getSuggestionsByDocumentId } from '@/lib/db/queries';
4 |
5 | export async function getSuggestions({ documentId }: { documentId: string }) {
6 | const suggestions = await getSuggestionsByDocumentId({ documentId });
7 | return suggestions ?? [];
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/artifacts/code/server.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { streamObject } from 'ai';
3 | import { myProvider } from '@/lib/ai/providers';
4 | import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
5 | import { createDocumentHandler } from '@/lib/artifacts/server';
6 |
7 | export const codeDocumentHandler = createDocumentHandler<'code'>({
8 | kind: 'code',
9 | onCreateDocument: async ({ title, dataStream }) => {
10 | let draftContent = '';
11 |
12 | const { fullStream } = streamObject({
13 | model: myProvider.languageModel('artifact-model'),
14 | system: codePrompt,
15 | prompt: title,
16 | schema: z.object({
17 | code: z.string(),
18 | }),
19 | });
20 |
21 | for await (const delta of fullStream) {
22 | const { type } = delta;
23 |
24 | if (type === 'object') {
25 | const { object } = delta;
26 | const { code } = object;
27 |
28 | if (code) {
29 | dataStream.writeData({
30 | type: 'code-delta',
31 | content: code ?? '',
32 | });
33 |
34 | draftContent = code;
35 | }
36 | }
37 | }
38 |
39 | return draftContent;
40 | },
41 | onUpdateDocument: async ({ document, description, dataStream }) => {
42 | let draftContent = '';
43 |
44 | const { fullStream } = streamObject({
45 | model: myProvider.languageModel('artifact-model'),
46 | system: updateDocumentPrompt(document.content, 'code'),
47 | prompt: description,
48 | schema: z.object({
49 | code: z.string(),
50 | }),
51 | });
52 |
53 | for await (const delta of fullStream) {
54 | const { type } = delta;
55 |
56 | if (type === 'object') {
57 | const { object } = delta;
58 | const { code } = object;
59 |
60 | if (code) {
61 | dataStream.writeData({
62 | type: 'code-delta',
63 | content: code ?? '',
64 | });
65 |
66 | draftContent = code;
67 | }
68 | }
69 | }
70 |
71 | return draftContent;
72 | },
73 | });
74 |
--------------------------------------------------------------------------------
/frontend/artifacts/image/client.tsx:
--------------------------------------------------------------------------------
1 | import { Artifact } from '@/components/create-artifact';
2 | import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons';
3 | import { ImageEditor } from '@/components/image-editor';
4 | import { toast } from 'sonner';
5 |
6 | export const imageArtifact = new Artifact({
7 | kind: 'image',
8 | description: 'Useful for image generation',
9 | onStreamPart: ({ streamPart, setArtifact }) => {
10 | if (streamPart.type === 'image-delta') {
11 | setArtifact((draftArtifact) => ({
12 | ...draftArtifact,
13 | content: streamPart.content as string,
14 | isVisible: true,
15 | status: 'streaming',
16 | }));
17 | }
18 | },
19 | content: ImageEditor,
20 | actions: [
21 | {
22 | icon: ,
23 | description: 'View Previous version',
24 | onClick: ({ handleVersionChange }) => {
25 | handleVersionChange('prev');
26 | },
27 | isDisabled: ({ currentVersionIndex }) => {
28 | if (currentVersionIndex === 0) {
29 | return true;
30 | }
31 |
32 | return false;
33 | },
34 | },
35 | {
36 | icon: ,
37 | description: 'View Next version',
38 | onClick: ({ handleVersionChange }) => {
39 | handleVersionChange('next');
40 | },
41 | isDisabled: ({ isCurrentVersion }) => {
42 | if (isCurrentVersion) {
43 | return true;
44 | }
45 |
46 | return false;
47 | },
48 | },
49 | {
50 | icon: ,
51 | description: 'Copy image to clipboard',
52 | onClick: ({ content }) => {
53 | const img = new Image();
54 | img.src = `data:image/png;base64,${content}`;
55 |
56 | img.onload = () => {
57 | const canvas = document.createElement('canvas');
58 | canvas.width = img.width;
59 | canvas.height = img.height;
60 | const ctx = canvas.getContext('2d');
61 | ctx?.drawImage(img, 0, 0);
62 | canvas.toBlob((blob) => {
63 | if (blob) {
64 | navigator.clipboard.write([
65 | new ClipboardItem({ 'image/png': blob }),
66 | ]);
67 | }
68 | }, 'image/png');
69 | };
70 |
71 | toast.success('Copied image to clipboard!');
72 | },
73 | },
74 | ],
75 | toolbar: [],
76 | });
77 |
--------------------------------------------------------------------------------
/frontend/artifacts/image/server.ts:
--------------------------------------------------------------------------------
1 | import { myProvider } from '@/lib/ai/providers';
2 | import { createDocumentHandler } from '@/lib/artifacts/server';
3 | import { experimental_generateImage } from 'ai';
4 |
5 | export const imageDocumentHandler = createDocumentHandler<'image'>({
6 | kind: 'image',
7 | onCreateDocument: async ({ title, dataStream }) => {
8 | let draftContent = '';
9 |
10 | const { image } = await experimental_generateImage({
11 | model: myProvider.imageModel('small-model'),
12 | prompt: title,
13 | n: 1,
14 | });
15 |
16 | draftContent = image.base64;
17 |
18 | dataStream.writeData({
19 | type: 'image-delta',
20 | content: image.base64,
21 | });
22 |
23 | return draftContent;
24 | },
25 | onUpdateDocument: async ({ description, dataStream }) => {
26 | let draftContent = '';
27 |
28 | const { image } = await experimental_generateImage({
29 | model: myProvider.imageModel('small-model'),
30 | prompt: description,
31 | n: 1,
32 | });
33 |
34 | draftContent = image.base64;
35 |
36 | dataStream.writeData({
37 | type: 'image-delta',
38 | content: image.base64,
39 | });
40 |
41 | return draftContent;
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/frontend/artifacts/sheet/server.ts:
--------------------------------------------------------------------------------
1 | import { myProvider } from '@/lib/ai/providers';
2 | import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
3 | import { createDocumentHandler } from '@/lib/artifacts/server';
4 | import { streamObject } from 'ai';
5 | import { z } from 'zod';
6 |
7 | export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
8 | kind: 'sheet',
9 | onCreateDocument: async ({ title, dataStream }) => {
10 | let draftContent = '';
11 |
12 | const { fullStream } = streamObject({
13 | model: myProvider.languageModel('artifact-model'),
14 | system: sheetPrompt,
15 | prompt: title,
16 | schema: z.object({
17 | csv: z.string().describe('CSV data'),
18 | }),
19 | });
20 |
21 | for await (const delta of fullStream) {
22 | const { type } = delta;
23 |
24 | if (type === 'object') {
25 | const { object } = delta;
26 | const { csv } = object;
27 |
28 | if (csv) {
29 | dataStream.writeData({
30 | type: 'sheet-delta',
31 | content: csv,
32 | });
33 |
34 | draftContent = csv;
35 | }
36 | }
37 | }
38 |
39 | dataStream.writeData({
40 | type: 'sheet-delta',
41 | content: draftContent,
42 | });
43 |
44 | return draftContent;
45 | },
46 | onUpdateDocument: async ({ document, description, dataStream }) => {
47 | let draftContent = '';
48 |
49 | const { fullStream } = streamObject({
50 | model: myProvider.languageModel('artifact-model'),
51 | system: updateDocumentPrompt(document.content, 'sheet'),
52 | prompt: description,
53 | schema: z.object({
54 | csv: z.string(),
55 | }),
56 | });
57 |
58 | for await (const delta of fullStream) {
59 | const { type } = delta;
60 |
61 | if (type === 'object') {
62 | const { object } = delta;
63 | const { csv } = object;
64 |
65 | if (csv) {
66 | dataStream.writeData({
67 | type: 'sheet-delta',
68 | content: csv,
69 | });
70 |
71 | draftContent = csv;
72 | }
73 | }
74 | }
75 |
76 | return draftContent;
77 | },
78 | });
79 |
--------------------------------------------------------------------------------
/frontend/artifacts/text/server.ts:
--------------------------------------------------------------------------------
1 | import { smoothStream, streamText } from 'ai';
2 | import { myProvider } from '@/lib/ai/providers';
3 | import { createDocumentHandler } from '@/lib/artifacts/server';
4 | import { updateDocumentPrompt } from '@/lib/ai/prompts';
5 |
6 | export const textDocumentHandler = createDocumentHandler<'text'>({
7 | kind: 'text',
8 | onCreateDocument: async ({ title, dataStream }) => {
9 | let draftContent = '';
10 |
11 | const { fullStream } = streamText({
12 | model: myProvider.languageModel('artifact-model'),
13 | system:
14 | 'Write about the given topic. Markdown is supported. Use headings wherever appropriate.',
15 | experimental_transform: smoothStream({ chunking: 'word' }),
16 | prompt: title,
17 | });
18 |
19 | for await (const delta of fullStream) {
20 | const { type } = delta;
21 |
22 | if (type === 'text-delta') {
23 | const { textDelta } = delta;
24 |
25 | draftContent += textDelta;
26 |
27 | dataStream.writeData({
28 | type: 'text-delta',
29 | content: textDelta,
30 | });
31 | }
32 | }
33 |
34 | return draftContent;
35 | },
36 | onUpdateDocument: async ({ document, description, dataStream }) => {
37 | let draftContent = '';
38 |
39 | const { fullStream } = streamText({
40 | model: myProvider.languageModel('artifact-model'),
41 | system: updateDocumentPrompt(document.content, 'text'),
42 | experimental_transform: smoothStream({ chunking: 'word' }),
43 | prompt: description,
44 | experimental_providerMetadata: {
45 | openai: {
46 | prediction: {
47 | type: 'content',
48 | content: document.content,
49 | },
50 | },
51 | },
52 | });
53 |
54 | for await (const delta of fullStream) {
55 | const { type } = delta;
56 |
57 | if (type === 'text-delta') {
58 | const { textDelta } = delta;
59 |
60 | draftContent += textDelta;
61 | dataStream.writeData({
62 | type: 'text-delta',
63 | content: textDelta,
64 | });
65 | }
66 | }
67 |
68 | return draftContent;
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/frontend/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": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/components/artifact-close-button.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { CrossIcon } from './icons';
3 | import { Button } from './ui/button';
4 | import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
5 |
6 | function PureArtifactCloseButton() {
7 | const { setArtifact } = useArtifact();
8 |
9 | return (
10 | {
15 | setArtifact((currentArtifact) =>
16 | currentArtifact.status === 'streaming'
17 | ? {
18 | ...currentArtifact,
19 | isVisible: false,
20 | }
21 | : { ...initialArtifactData, status: 'idle' },
22 | );
23 | }}
24 | >
25 |
26 |
27 | );
28 | }
29 |
30 | export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
31 |
--------------------------------------------------------------------------------
/frontend/components/auth-form.tsx:
--------------------------------------------------------------------------------
1 | import Form from 'next/form';
2 |
3 | import { Input } from './ui/input';
4 | import { Label } from './ui/label';
5 |
6 | export function AuthForm({
7 | action,
8 | children,
9 | defaultEmail = '',
10 | }: {
11 | action: NonNullable<
12 | string | ((formData: FormData) => void | Promise) | undefined
13 | >;
14 | children: React.ReactNode;
15 | defaultEmail?: string;
16 | }) {
17 | return (
18 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/frontend/components/code-block.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | interface CodeBlockProps {
4 | node: any;
5 | inline: boolean;
6 | className: string;
7 | children: any;
8 | }
9 |
10 | export function CodeBlock({
11 | node,
12 | inline,
13 | className,
14 | children,
15 | ...props
16 | }: CodeBlockProps) {
17 | if (!inline) {
18 | return (
19 |
20 |
24 | {children}
25 |
26 |
27 | );
28 | } else {
29 | return (
30 |
34 | {children}
35 |
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/components/connection-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import { SidebarToggle } from "@/components/sidebar-toggle";
5 |
6 | export function ConnectionHeader() {
7 | const pathname = usePathname();
8 | const pathSegments = pathname?.split("/") || [];
9 | const lastSegment = pathSegments[pathSegments.length - 1] || "Apps";
10 |
11 | const titleMap: Record = {
12 | "mcp-templates": "MCP Templates",
13 | logs: "Logs",
14 | "mcp-servers": "MCP Servers",
15 | };
16 |
17 | const title = titleMap[lastSegment] || lastSegment;
18 |
19 | return (
20 |
21 |
22 | {title}
23 |
{/* Spacer to balance the layout */}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/components/document-skeleton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ArtifactKind } from './artifact';
4 |
5 | export const DocumentSkeleton = ({
6 | artifactKind,
7 | }: {
8 | artifactKind: ArtifactKind;
9 | }) => {
10 | return artifactKind === 'image' ? (
11 |
14 | ) : (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export const InlineDocumentSkeleton = () => {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/frontend/components/image-editor.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderIcon } from './icons';
2 | import cn from 'classnames';
3 |
4 | interface ImageEditorProps {
5 | title: string;
6 | content: string;
7 | isCurrentVersion: boolean;
8 | currentVersionIndex: number;
9 | status: string;
10 | isInline: boolean;
11 | }
12 |
13 | export function ImageEditor({
14 | title,
15 | content,
16 | status,
17 | isInline,
18 | }: ImageEditorProps) {
19 | return (
20 |
26 | {status === 'streaming' ? (
27 |
28 | {!isInline && (
29 |
30 |
31 |
32 | )}
33 |
Generating Image...
34 |
35 | ) : (
36 |
37 |
44 |
45 | )}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/components/message-reasoning.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { ChevronDownIcon, LoaderIcon } from './icons';
5 | import { motion, AnimatePresence } from 'framer-motion';
6 | import { Markdown } from './markdown';
7 |
8 | interface MessageReasoningProps {
9 | isLoading: boolean;
10 | reasoning: string;
11 | }
12 |
13 | export function MessageReasoning({
14 | isLoading,
15 | reasoning,
16 | }: MessageReasoningProps) {
17 | const [isExpanded, setIsExpanded] = useState(true);
18 |
19 | const variants = {
20 | collapsed: {
21 | height: 0,
22 | opacity: 0,
23 | marginTop: 0,
24 | marginBottom: 0,
25 | },
26 | expanded: {
27 | height: 'auto',
28 | opacity: 1,
29 | marginTop: '1rem',
30 | marginBottom: '0.5rem',
31 | },
32 | };
33 |
34 | return (
35 |
36 | {isLoading ? (
37 |
38 |
Reasoning
39 |
40 |
41 |
42 |
43 | ) : (
44 |
45 |
Reasoned for a few seconds
46 |
{
51 | setIsExpanded(!isExpanded);
52 | }}
53 | >
54 |
55 |
56 |
57 | )}
58 |
59 |
60 | {isExpanded && (
61 |
72 | {reasoning}
73 |
74 | )}
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/frontend/components/nav-documents.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | FolderIcon,
5 | MoreHorizontalIcon,
6 | ShareIcon,
7 | type LucideIcon,
8 | } from "lucide-react";
9 |
10 | import {
11 | DropdownMenu,
12 | DropdownMenuContent,
13 | DropdownMenuItem,
14 | DropdownMenuTrigger,
15 | } from "@/components/ui/dropdown-menu";
16 | import {
17 | SidebarGroup,
18 | SidebarGroupLabel,
19 | SidebarMenu,
20 | SidebarMenuAction,
21 | SidebarMenuButton,
22 | SidebarMenuItem,
23 | useSidebar,
24 | } from "@/components/ui/sidebar";
25 |
26 | export function NavDocuments({
27 | items,
28 | }: {
29 | items: {
30 | name: string;
31 | url: string;
32 | icon: LucideIcon;
33 | }[];
34 | }) {
35 | const { isMobile } = useSidebar();
36 |
37 | return (
38 |
39 | Documents
40 |
41 | {items.map((item) => (
42 |
43 |
44 |
45 |
46 | {item.name}
47 |
48 |
49 |
50 |
51 |
55 |
56 | More
57 |
58 |
59 |
64 |
65 |
66 | Open
67 |
68 |
69 |
70 | Share
71 |
72 |
73 |
74 |
75 | ))}
76 |
77 |
78 |
79 | More
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/components/nav-main.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MailIcon, PlusCircleIcon, type LucideIcon } from "lucide-react";
4 | import Link from "next/link";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import {
8 | SidebarGroup,
9 | SidebarGroupContent,
10 | SidebarMenu,
11 | SidebarMenuButton,
12 | SidebarMenuItem,
13 | } from "@/components/ui/sidebar";
14 |
15 | export function NavMain({
16 | items,
17 | }: {
18 | items: {
19 | title: string;
20 | url: string;
21 | icon?: LucideIcon;
22 | }[];
23 | }) {
24 | return (
25 |
26 |
27 | {/*
28 |
29 |
33 |
34 | Quick Create
35 |
36 |
41 |
42 | Inbox
43 |
44 |
45 | */}
46 |
47 | {items.map((item) => (
48 |
49 |
50 |
51 | {item.icon && }
52 | {item.title}
53 |
54 |
55 |
56 | ))}
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/components/nav-secondary.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { LucideIcon } from "lucide-react";
5 |
6 | import {
7 | SidebarGroup,
8 | SidebarGroupContent,
9 | SidebarMenu,
10 | SidebarMenuButton,
11 | SidebarMenuItem,
12 | } from "@/components/ui/sidebar";
13 |
14 | export function NavSecondary({
15 | items,
16 | ...props
17 | }: {
18 | items: {
19 | title: string;
20 | url: string;
21 | icon: LucideIcon;
22 | }[];
23 | } & React.ComponentPropsWithoutRef) {
24 | return (
25 |
26 |
27 |
28 | {items.map((item) => (
29 |
30 |
31 |
32 |
33 | {item.title}
34 |
35 |
36 |
37 | ))}
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/components/overview.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import Link from 'next/link';
3 |
4 | import { MessageIcon, VercelIcon } from './icons';
5 |
6 | export const Overview = () => {
7 | return (
8 |
16 |
17 | {/*
18 |
19 | +
20 |
21 |
22 |
23 | This is an{' '}
24 |
29 | open source
30 | {' '}
31 | chatbot template built with Next.js and the AI SDK by Vercel. It uses
32 | the{' '}
33 | streamText
{' '}
34 | function in the server and the{' '}
35 | useChat
hook
36 | on the client to create a seamless chat experience.
37 |
38 |
39 | You can learn more about the AI SDK by visiting the{' '}
40 |
45 | docs
46 |
47 | .
48 |
*/}
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/frontend/components/preview-attachment.tsx:
--------------------------------------------------------------------------------
1 | import type { Attachment } from 'ai';
2 |
3 | import { LoaderIcon } from './icons';
4 |
5 | export const PreviewAttachment = ({
6 | attachment,
7 | isUploading = false,
8 | }: {
9 | attachment: Attachment;
10 | isUploading?: boolean;
11 | }) => {
12 | const { name, url, contentType } = attachment;
13 |
14 | return (
15 |
16 |
17 | {contentType ? (
18 | contentType.startsWith('image') ? (
19 | // NOTE: it is recommended to use next/image for images
20 | // eslint-disable-next-line @next/next/no-img-element
21 |
27 | ) : (
28 |
29 | )
30 | ) : (
31 |
32 | )}
33 |
34 | {isUploading && (
35 |
39 |
40 |
41 | )}
42 |
43 |
{name}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/frontend/components/sidebar-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { ComponentProps } from "react";
4 |
5 | import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
6 | import {
7 | Tooltip,
8 | TooltipContent,
9 | TooltipTrigger,
10 | } from "@/components/ui/tooltip";
11 |
12 | import { SidebarLeftIcon } from "./icons";
13 | import { Button } from "./ui/button";
14 |
15 | export function SidebarToggle({
16 | className,
17 | }: ComponentProps) {
18 | const { toggleSidebar } = useSidebar();
19 |
20 | return (
21 |
22 |
23 |
28 |
29 |
30 |
31 | Toggle Sidebar
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/components/sidebar-user-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { ChevronUp } from 'lucide-react';
3 | import Image from 'next/image';
4 | import type { User } from 'next-auth';
5 | import { signOut } from 'next-auth/react';
6 | import { useTheme } from 'next-themes';
7 |
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuSeparator,
13 | DropdownMenuTrigger,
14 | } from '@/components/ui/dropdown-menu';
15 | import {
16 | SidebarMenu,
17 | SidebarMenuButton,
18 | SidebarMenuItem,
19 | } from '@/components/ui/sidebar';
20 |
21 | export function SidebarUserNav({ user }: { user: User }) {
22 | const { setTheme, theme } = useTheme();
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
37 | {user?.email}
38 |
39 |
40 |
41 |
45 | setTheme(theme === 'dark' ? 'light' : 'dark')}
48 | >
49 | {`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
50 |
51 |
52 |
53 | {
57 | signOut({
58 | redirectTo: '/',
59 | });
60 | }}
61 | >
62 | Sign out
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/frontend/components/sign-out-form.tsx:
--------------------------------------------------------------------------------
1 | import Form from 'next/form';
2 |
3 | import { signOut } from '@/app/(auth)/auth';
4 |
5 | export const SignOutForm = () => {
6 | return (
7 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/frontend/components/submit-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useFormStatus } from 'react-dom';
4 |
5 | import { LoaderIcon } from '@/components/icons';
6 |
7 | import { Button } from './ui/button';
8 |
9 | export function SubmitButton({
10 | children,
11 | isSuccessful,
12 | }: {
13 | children: React.ReactNode;
14 | isSuccessful: boolean;
15 | }) {
16 | const { pending } = useFormStatus();
17 |
18 | return (
19 |
25 | {children}
26 |
27 | {(pending || isSuccessful) && (
28 |
29 |
30 |
31 | )}
32 |
33 |
34 | {pending || isSuccessful ? 'Loading' : 'Submit form'}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
4 | import type { ThemeProviderProps } from 'next-themes/dist/types';
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children} ;
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/components/toast.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { ReactNode } from 'react';
4 | import { toast as sonnerToast } from 'sonner';
5 | import { CheckCircleFillIcon, WarningIcon } from './icons';
6 |
7 | const iconsByType: Record<'success' | 'error', ReactNode> = {
8 | success: ,
9 | error: ,
10 | };
11 |
12 | export function toast(props: Omit) {
13 | return sonnerToast.custom((id) => (
14 |
15 | ));
16 | }
17 |
18 | function Toast(props: ToastProps) {
19 | const { id, type, description } = props;
20 |
21 | return (
22 |
23 |
28 |
32 | {iconsByType[type]}
33 |
34 |
{description}
35 |
36 |
37 | );
38 | }
39 |
40 | interface ToastProps {
41 | id: string | number;
42 | type: 'success' | 'error';
43 | description: string;
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/components/ui/alert.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 alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | },
20 | );
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ));
33 | Alert.displayName = "Alert";
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ));
45 | AlertTitle.displayName = "AlertTitle";
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | AlertDescription.displayName = "AlertDescription";
58 |
59 | export { Alert, AlertTitle, AlertDescription };
60 |
--------------------------------------------------------------------------------
/frontend/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-md 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 shadow 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 shadow 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,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/frontend/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';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
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,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button';
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = 'Button';
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/frontend/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = 'Card';
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = 'CardHeader';
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = 'CardTitle';
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLDivElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = 'CardDescription';
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = 'CardContent';
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = 'CardFooter';
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/frontend/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,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/frontend/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | },
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/frontend/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';
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,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/frontend/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
5 | import { Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | );
20 | });
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | );
41 | });
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
43 |
44 | export { RadioGroup, RadioGroupItem };
45 |
--------------------------------------------------------------------------------
/frontend/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ));
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ));
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
47 |
48 | export { ScrollArea, ScrollBar };
49 |
--------------------------------------------------------------------------------
/frontend/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';
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = 'horizontal', decorative = true, ...props },
14 | ref,
15 | ) => (
16 |
27 | ),
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/frontend/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | );
13 | }
14 |
15 | export { Skeleton };
16 |
--------------------------------------------------------------------------------
/frontend/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,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/frontend/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TabsPrimitive from "@radix-ui/react-tabs";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/frontend/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<'textarea'>
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = 'Textarea';
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/frontend/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';
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,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
31 |
--------------------------------------------------------------------------------
/frontend/components/use-scroll-to-bottom.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, type RefObject } from 'react';
2 |
3 | export function useScrollToBottom(): [
4 | RefObject,
5 | RefObject,
6 | ] {
7 | const containerRef = useRef(null);
8 | const endRef = useRef(null);
9 |
10 | useEffect(() => {
11 | const container = containerRef.current;
12 | const end = endRef.current;
13 |
14 | if (container && end) {
15 | const observer = new MutationObserver(() => {
16 | end.scrollIntoView({ behavior: 'instant', block: 'end' });
17 | });
18 |
19 | observer.observe(container, {
20 | childList: true,
21 | subtree: true,
22 | attributes: true,
23 | characterData: true,
24 | });
25 |
26 | return () => observer.disconnect();
27 | }
28 | }, []);
29 |
30 | return [containerRef, endRef];
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { config } from 'dotenv';
2 | import { defineConfig } from 'drizzle-kit';
3 |
4 | config({
5 | path: '.env.local',
6 | });
7 |
8 | export default defineConfig({
9 | schema: './lib/db/schema.ts',
10 | out: './lib/db/migrations',
11 | dialect: 'postgresql',
12 | dbCredentials: {
13 | // biome-ignore lint: Forbidden non-null assertion.
14 | url: process.env.POSTGRES_URL!,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/frontend/hooks/use-artifact.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import useSWR from 'swr';
4 | import { UIArtifact } from '@/components/artifact';
5 | import { useCallback, useMemo } from 'react';
6 |
7 | export const initialArtifactData: UIArtifact = {
8 | documentId: 'init',
9 | content: '',
10 | kind: 'text',
11 | title: '',
12 | status: 'idle',
13 | isVisible: false,
14 | boundingBox: {
15 | top: 0,
16 | left: 0,
17 | width: 0,
18 | height: 0,
19 | },
20 | };
21 |
22 | type Selector = (state: UIArtifact) => T;
23 |
24 | export function useArtifactSelector(selector: Selector) {
25 | const { data: localArtifact } = useSWR('artifact', null, {
26 | fallbackData: initialArtifactData,
27 | });
28 |
29 | const selectedValue = useMemo(() => {
30 | if (!localArtifact) return selector(initialArtifactData);
31 | return selector(localArtifact);
32 | }, [localArtifact, selector]);
33 |
34 | return selectedValue;
35 | }
36 |
37 | export function useArtifact() {
38 | const { data: localArtifact, mutate: setLocalArtifact } = useSWR(
39 | 'artifact',
40 | null,
41 | {
42 | fallbackData: initialArtifactData,
43 | },
44 | );
45 |
46 | const artifact = useMemo(() => {
47 | if (!localArtifact) return initialArtifactData;
48 | return localArtifact;
49 | }, [localArtifact]);
50 |
51 | const setArtifact = useCallback(
52 | (updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)) => {
53 | setLocalArtifact((currentArtifact) => {
54 | const artifactToUpdate = currentArtifact || initialArtifactData;
55 |
56 | if (typeof updaterFn === 'function') {
57 | return updaterFn(artifactToUpdate);
58 | }
59 |
60 | return updaterFn;
61 | });
62 | },
63 | [setLocalArtifact],
64 | );
65 |
66 | const { data: localArtifactMetadata, mutate: setLocalArtifactMetadata } =
67 | useSWR(
68 | () =>
69 | artifact.documentId ? `artifact-metadata-${artifact.documentId}` : null,
70 | null,
71 | {
72 | fallbackData: null,
73 | },
74 | );
75 |
76 | return useMemo(
77 | () => ({
78 | artifact,
79 | setArtifact,
80 | metadata: localArtifactMetadata,
81 | setMetadata: setLocalArtifactMetadata,
82 | }),
83 | [artifact, setArtifact, localArtifactMetadata, setLocalArtifactMetadata],
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/hooks/use-chat-visibility.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { updateChatVisibility } from '@/app/(chat)/actions';
4 | import { VisibilityType } from '@/components/visibility-selector';
5 | import { Chat } from '@/lib/db/schema';
6 | import { useMemo } from 'react';
7 | import useSWR, { useSWRConfig } from 'swr';
8 |
9 | export function useChatVisibility({
10 | chatId,
11 | initialVisibility,
12 | }: {
13 | chatId: string;
14 | initialVisibility: VisibilityType;
15 | }) {
16 | const { mutate, cache } = useSWRConfig();
17 | const history: Array = cache.get('/api/history')?.data;
18 |
19 | const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
20 | `${chatId}-visibility`,
21 | null,
22 | {
23 | fallbackData: initialVisibility,
24 | },
25 | );
26 |
27 | const visibilityType = useMemo(() => {
28 | if (!history) return localVisibility;
29 | const chat = history.find((chat) => chat.id === chatId);
30 | if (!chat) return 'private';
31 | return chat.visibility;
32 | }, [history, chatId, localVisibility]);
33 |
34 | const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
35 | setLocalVisibility(updatedVisibilityType);
36 |
37 | mutate>(
38 | '/api/history',
39 | (history) => {
40 | return history
41 | ? history.map((chat) => {
42 | if (chat.id === chatId) {
43 | return {
44 | ...chat,
45 | visibility: updatedVisibilityType,
46 | };
47 | }
48 | return chat;
49 | })
50 | : [];
51 | },
52 | { revalidate: false },
53 | );
54 |
55 | updateChatVisibility({
56 | chatId: chatId,
57 | visibility: updatedVisibilityType,
58 | });
59 | };
60 |
61 | return { visibilityType, setVisibilityType };
62 | }
63 |
--------------------------------------------------------------------------------
/frontend/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(
7 | undefined,
8 | );
9 |
10 | React.useEffect(() => {
11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12 | const onChange = () => {
13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14 | };
15 | mql.addEventListener('change', onChange);
16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17 | return () => mql.removeEventListener('change', onChange);
18 | }, []);
19 |
20 | return !!isMobile;
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/lib/ai/models.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_CHAT_MODEL: string = 'chat-model';
2 |
3 | interface ChatModel {
4 | id: string;
5 | name: string;
6 | description: string;
7 | }
8 |
9 | export const chatModels: Array = [
10 | {
11 | id: 'chat-model',
12 | name: 'Chat model',
13 | description: 'Primary model for all-purpose chat',
14 | },
15 | {
16 | id: 'chat-model-reasoning',
17 | name: 'Reasoning model',
18 | description: 'Uses advanced reasoning',
19 | },
20 | ];
21 |
--------------------------------------------------------------------------------
/frontend/lib/ai/providers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | customProvider,
3 | extractReasoningMiddleware,
4 | wrapLanguageModel,
5 | } from 'ai';
6 | import { openai } from '@ai-sdk/openai';
7 | import { groq } from '@ai-sdk/groq';
8 | import { xai } from '@ai-sdk/xai';
9 | import { isTestEnvironment } from '../constants';
10 | import {
11 | artifactModel,
12 | chatModel,
13 | reasoningModel,
14 | titleModel,
15 | } from './models.test';
16 |
17 | export const myProvider = isTestEnvironment
18 | ? customProvider({
19 | languageModels: {
20 | 'chat-model': chatModel,
21 | 'chat-model-reasoning': reasoningModel,
22 | 'title-model': titleModel,
23 | 'artifact-model': artifactModel,
24 | },
25 | })
26 | : customProvider({
27 | languageModels: {
28 | 'chat-model': openai('gpt-4o-mini'),
29 | 'chat-model-reasoning': wrapLanguageModel({
30 | model: groq('deepseek-r1-distill-llama-70b'),
31 | middleware: extractReasoningMiddleware({ tagName: 'think' }),
32 | }),
33 | 'title-model': openai('gpt-4o-mini'),
34 | 'artifact-model': openai('gpt-4o-mini'),
35 | },
36 | imageModels: {
37 | 'small-model': xai.image('grok-2-image'),
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/frontend/lib/ai/tools/create-document.ts:
--------------------------------------------------------------------------------
1 | import { generateUUID } from '@/lib/utils';
2 | import { DataStreamWriter, tool } from 'ai';
3 | import { z } from 'zod';
4 | import { Session } from 'next-auth';
5 | import {
6 | artifactKinds,
7 | documentHandlersByArtifactKind,
8 | } from '@/lib/artifacts/server';
9 |
10 | interface CreateDocumentProps {
11 | session: Session;
12 | dataStream: DataStreamWriter;
13 | }
14 |
15 | export const createDocument = ({ session, dataStream }: CreateDocumentProps) =>
16 | tool({
17 | description:
18 | 'Create a document for a writing or content creation activities. This tool will call other functions that will generate the contents of the document based on the title and kind.',
19 | parameters: z.object({
20 | title: z.string(),
21 | kind: z.enum(artifactKinds),
22 | }),
23 | execute: async ({ title, kind }) => {
24 | const id = generateUUID();
25 |
26 | dataStream.writeData({
27 | type: 'kind',
28 | content: kind,
29 | });
30 |
31 | dataStream.writeData({
32 | type: 'id',
33 | content: id,
34 | });
35 |
36 | dataStream.writeData({
37 | type: 'title',
38 | content: title,
39 | });
40 |
41 | dataStream.writeData({
42 | type: 'clear',
43 | content: '',
44 | });
45 |
46 | const documentHandler = documentHandlersByArtifactKind.find(
47 | (documentHandlerByArtifactKind) =>
48 | documentHandlerByArtifactKind.kind === kind,
49 | );
50 |
51 | if (!documentHandler) {
52 | throw new Error(`No document handler found for kind: ${kind}`);
53 | }
54 |
55 | await documentHandler.onCreateDocument({
56 | id,
57 | title,
58 | dataStream,
59 | session,
60 | });
61 |
62 | dataStream.writeData({ type: 'finish', content: '' });
63 |
64 | return {
65 | id,
66 | title,
67 | kind,
68 | content: 'A document was created and is now visible to the user.',
69 | };
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/frontend/lib/ai/tools/get-weather.ts:
--------------------------------------------------------------------------------
1 | import { tool } from 'ai';
2 | import { z } from 'zod';
3 |
4 | export const getWeather = tool({
5 | description: 'Get the current weather at a location',
6 | parameters: z.object({
7 | latitude: z.number(),
8 | longitude: z.number(),
9 | }),
10 | execute: async ({ latitude, longitude }) => {
11 | const response = await fetch(
12 | `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
13 | );
14 |
15 | const weatherData = await response.json();
16 | return weatherData;
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/frontend/lib/ai/tools/update-document.ts:
--------------------------------------------------------------------------------
1 | import { DataStreamWriter, tool } from 'ai';
2 | import { Session } from 'next-auth';
3 | import { z } from 'zod';
4 | import { getDocumentById, saveDocument } from '@/lib/db/queries';
5 | import { documentHandlersByArtifactKind } from '@/lib/artifacts/server';
6 |
7 | interface UpdateDocumentProps {
8 | session: Session;
9 | dataStream: DataStreamWriter;
10 | }
11 |
12 | export const updateDocument = ({ session, dataStream }: UpdateDocumentProps) =>
13 | tool({
14 | description: 'Update a document with the given description.',
15 | parameters: z.object({
16 | id: z.string().describe('The ID of the document to update'),
17 | description: z
18 | .string()
19 | .describe('The description of changes that need to be made'),
20 | }),
21 | execute: async ({ id, description }) => {
22 | const document = await getDocumentById({ id });
23 |
24 | if (!document) {
25 | return {
26 | error: 'Document not found',
27 | };
28 | }
29 |
30 | dataStream.writeData({
31 | type: 'clear',
32 | content: document.title,
33 | });
34 |
35 | const documentHandler = documentHandlersByArtifactKind.find(
36 | (documentHandlerByArtifactKind) =>
37 | documentHandlerByArtifactKind.kind === document.kind,
38 | );
39 |
40 | if (!documentHandler) {
41 | throw new Error(`No document handler found for kind: ${document.kind}`);
42 | }
43 |
44 | await documentHandler.onUpdateDocument({
45 | document,
46 | description,
47 | dataStream,
48 | session,
49 | });
50 |
51 | dataStream.writeData({ type: 'finish', content: '' });
52 |
53 | return {
54 | id,
55 | title: document.title,
56 | kind: document.kind,
57 | content: 'The document has been updated successfully.',
58 | };
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/frontend/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const isProductionEnvironment = process.env.NODE_ENV === 'production';
2 |
3 | export const isTestEnvironment = Boolean(
4 | process.env.PLAYWRIGHT_TEST_BASE_URL ||
5 | process.env.PLAYWRIGHT ||
6 | process.env.CI_PLAYWRIGHT,
7 | );
8 |
--------------------------------------------------------------------------------
/frontend/lib/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import { config } from 'dotenv';
2 | import { drizzle } from 'drizzle-orm/postgres-js';
3 | import { migrate } from 'drizzle-orm/postgres-js/migrator';
4 | import postgres from 'postgres';
5 |
6 | config({
7 | path: '.env.local',
8 | });
9 |
10 | const runMigrate = async () => {
11 | if (!process.env.POSTGRES_URL) {
12 | throw new Error('POSTGRES_URL is not defined');
13 | }
14 |
15 | const connection = postgres(process.env.POSTGRES_URL, { max: 1 });
16 | const db = drizzle(connection);
17 |
18 | console.log('⏳ Running migrations...');
19 |
20 | const start = Date.now();
21 | await migrate(db, { migrationsFolder: './lib/db/migrations' });
22 | const end = Date.now();
23 |
24 | console.log('✅ Migrations completed in', end - start, 'ms');
25 | process.exit(0);
26 | };
27 |
28 | runMigrate().catch((err) => {
29 | console.error('❌ Migration failed');
30 | console.error(err);
31 | process.exit(1);
32 | });
33 |
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0000_keen_devos.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Chat" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "createdAt" timestamp NOT NULL,
4 | "messages" json NOT NULL,
5 | "userId" uuid NOT NULL
6 | );
7 | --> statement-breakpoint
8 | CREATE TABLE IF NOT EXISTS "User" (
9 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
10 | "email" varchar(64) NOT NULL,
11 | "password" varchar(64)
12 | );
13 | --> statement-breakpoint
14 | DO $$ BEGIN
15 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 |
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0001_sparkling_blue_marvel.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Suggestion" (
2 | "id" uuid DEFAULT gen_random_uuid() NOT NULL,
3 | "documentId" uuid NOT NULL,
4 | "documentCreatedAt" timestamp NOT NULL,
5 | "originalText" text NOT NULL,
6 | "suggestedText" text NOT NULL,
7 | "description" text,
8 | "isResolved" boolean DEFAULT false NOT NULL,
9 | "userId" uuid NOT NULL,
10 | "createdAt" timestamp NOT NULL,
11 | CONSTRAINT "Suggestion_id_pk" PRIMARY KEY("id")
12 | );
13 | --> statement-breakpoint
14 | CREATE TABLE IF NOT EXISTS "Document" (
15 | "id" uuid DEFAULT gen_random_uuid() NOT NULL,
16 | "createdAt" timestamp NOT NULL,
17 | "title" text NOT NULL,
18 | "content" text,
19 | "userId" uuid NOT NULL,
20 | CONSTRAINT "Document_id_createdAt_pk" PRIMARY KEY("id","createdAt")
21 | );
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk" FOREIGN KEY ("documentId","documentCreatedAt") REFERENCES "public"."Document"("id","createdAt") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 | --> statement-breakpoint
35 | DO $$ BEGIN
36 | ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
37 | EXCEPTION
38 | WHEN duplicate_object THEN null;
39 | END $$;
40 |
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0002_wandering_riptide.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Message" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "chatId" uuid NOT NULL,
4 | "role" varchar NOT NULL,
5 | "content" json NOT NULL,
6 | "createdAt" timestamp NOT NULL
7 | );
8 | --> statement-breakpoint
9 | CREATE TABLE IF NOT EXISTS "Vote" (
10 | "chatId" uuid NOT NULL,
11 | "messageId" uuid NOT NULL,
12 | "isUpvoted" boolean NOT NULL,
13 | CONSTRAINT "Vote_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
14 | );
15 | --> statement-breakpoint
16 | ALTER TABLE "Chat" ADD COLUMN "title" text NOT NULL;--> statement-breakpoint
17 | DO $$ BEGIN
18 | ALTER TABLE "Message" ADD CONSTRAINT "Message_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
19 | EXCEPTION
20 | WHEN duplicate_object THEN null;
21 | END $$;
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Vote" ADD CONSTRAINT "Vote_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Vote" ADD CONSTRAINT "Vote_messageId_Message_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 | --> statement-breakpoint
35 | ALTER TABLE "Chat" DROP COLUMN IF EXISTS "messages";
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0003_cloudy_glorian.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "Chat" ADD COLUMN "visibility" varchar DEFAULT 'private' NOT NULL;
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0004_odd_slayback.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "Document" ADD COLUMN "text" varchar DEFAULT 'text' NOT NULL;
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/0005_wooden_whistler.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Message_v2" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "chatId" uuid NOT NULL,
4 | "role" varchar NOT NULL,
5 | "parts" json NOT NULL,
6 | "attachments" json NOT NULL,
7 | "createdAt" timestamp NOT NULL
8 | );
9 | --> statement-breakpoint
10 | CREATE TABLE IF NOT EXISTS "Vote_v2" (
11 | "chatId" uuid NOT NULL,
12 | "messageId" uuid NOT NULL,
13 | "isUpvoted" boolean NOT NULL,
14 | CONSTRAINT "Vote_v2_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
15 | );
16 | --> statement-breakpoint
17 | DO $$ BEGIN
18 | ALTER TABLE "Message_v2" ADD CONSTRAINT "Message_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
19 | EXCEPTION
20 | WHEN duplicate_object THEN null;
21 | END $$;
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 |
--------------------------------------------------------------------------------
/frontend/lib/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1728598022383,
9 | "tag": "0000_keen_devos",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1730207363999,
16 | "tag": "0001_sparkling_blue_marvel",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1730725226313,
23 | "tag": "0002_wandering_riptide",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1733403031014,
30 | "tag": "0003_cloudy_glorian",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1733945232355,
37 | "tag": "0004_odd_slayback",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "7",
43 | "when": 1741934630596,
44 | "tag": "0005_wooden_whistler",
45 | "breakpoints": true
46 | }
47 | ]
48 | }
--------------------------------------------------------------------------------
/frontend/lib/editor/config.ts:
--------------------------------------------------------------------------------
1 | import { textblockTypeInputRule } from 'prosemirror-inputrules';
2 | import { Schema } from 'prosemirror-model';
3 | import { schema } from 'prosemirror-schema-basic';
4 | import { addListNodes } from 'prosemirror-schema-list';
5 | import type { Transaction } from 'prosemirror-state';
6 | import type { EditorView } from 'prosemirror-view';
7 | import type { MutableRefObject } from 'react';
8 |
9 | import { buildContentFromDocument } from './functions';
10 |
11 | export const documentSchema = new Schema({
12 | nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
13 | marks: schema.spec.marks,
14 | });
15 |
16 | export function headingRule(level: number) {
17 | return textblockTypeInputRule(
18 | new RegExp(`^(#{1,${level}})\\s$`),
19 | documentSchema.nodes.heading,
20 | () => ({ level }),
21 | );
22 | }
23 |
24 | export const handleTransaction = ({
25 | transaction,
26 | editorRef,
27 | onSaveContent,
28 | }: {
29 | transaction: Transaction;
30 | editorRef: MutableRefObject;
31 | onSaveContent: (updatedContent: string, debounce: boolean) => void;
32 | }) => {
33 | if (!editorRef || !editorRef.current) return;
34 |
35 | const newState = editorRef.current.state.apply(transaction);
36 | editorRef.current.updateState(newState);
37 |
38 | if (transaction.docChanged && !transaction.getMeta('no-save')) {
39 | const updatedContent = buildContentFromDocument(newState.doc);
40 |
41 | if (transaction.getMeta('no-debounce')) {
42 | onSaveContent(updatedContent, false);
43 | } else {
44 | onSaveContent(updatedContent, true);
45 | }
46 | }
47 | };
48 |
--------------------------------------------------------------------------------
/frontend/lib/editor/functions.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { defaultMarkdownSerializer } from 'prosemirror-markdown';
4 | import { DOMParser, type Node } from 'prosemirror-model';
5 | import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view';
6 | import { renderToString } from 'react-dom/server';
7 |
8 | import { Markdown } from '@/components/markdown';
9 |
10 | import { documentSchema } from './config';
11 | import { createSuggestionWidget, type UISuggestion } from './suggestions';
12 |
13 | export const buildDocumentFromContent = (content: string) => {
14 | const parser = DOMParser.fromSchema(documentSchema);
15 | const stringFromMarkdown = renderToString({content} );
16 | const tempContainer = document.createElement('div');
17 | tempContainer.innerHTML = stringFromMarkdown;
18 | return parser.parse(tempContainer);
19 | };
20 |
21 | export const buildContentFromDocument = (document: Node) => {
22 | return defaultMarkdownSerializer.serialize(document);
23 | };
24 |
25 | export const createDecorations = (
26 | suggestions: Array,
27 | view: EditorView,
28 | ) => {
29 | const decorations: Array = [];
30 |
31 | for (const suggestion of suggestions) {
32 | decorations.push(
33 | Decoration.inline(
34 | suggestion.selectionStart,
35 | suggestion.selectionEnd,
36 | {
37 | class: 'suggestion-highlight',
38 | },
39 | {
40 | suggestionId: suggestion.id,
41 | type: 'highlight',
42 | },
43 | ),
44 | );
45 |
46 | decorations.push(
47 | Decoration.widget(
48 | suggestion.selectionStart,
49 | (view) => {
50 | const { dom } = createSuggestionWidget(suggestion, view);
51 | return dom;
52 | },
53 | {
54 | suggestionId: suggestion.id,
55 | type: 'widget',
56 | },
57 | ),
58 | );
59 | }
60 |
61 | return DecorationSet.create(view.state.doc, decorations);
62 | };
63 |
--------------------------------------------------------------------------------
/frontend/lib/editor/react-renderer.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 |
3 | export class ReactRenderer {
4 | static render(component: React.ReactElement, dom: HTMLElement) {
5 | const root = createRoot(dom);
6 | root.render(component);
7 |
8 | return {
9 | destroy: () => root.unmount(),
10 | };
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/middleware.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 |
3 | import { authConfig } from '@/app/(auth)/auth.config';
4 |
5 | export default NextAuth(authConfig).auth;
6 |
7 | export const config = {
8 | matcher: ['/', '/:id', '/api/:path*', '/login', '/register'],
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/frontend/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 |
3 | const nextConfig: NextConfig = {
4 | experimental: {
5 | ppr: true,
6 | },
7 | output: 'standalone',
8 | images: {
9 | remotePatterns: [
10 | {
11 | hostname: 'avatar.vercel.sh',
12 | },
13 | ],
14 | },
15 | };
16 |
17 | export default nextConfig;
18 |
--------------------------------------------------------------------------------
/frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | 'tailwindcss/nesting': {},
6 | },
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/frontend/public/images/demo-thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/frontend/public/images/demo-thumbnail.png
--------------------------------------------------------------------------------
/frontend/public/images/mouth of the seine, monet.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NeuclaiLabs/centroid/ec2c4b619cc246eb30912413ac0142db56d7de52/frontend/public/images/mouth of the seine, monet.jpg
--------------------------------------------------------------------------------
/frontend/tests/auth.setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { generateId } from 'ai';
3 | import { getUnixTime } from 'date-fns';
4 | import { expect, test as setup } from '@playwright/test';
5 |
6 | const authFile = path.join(__dirname, '../playwright/.auth/session.json');
7 |
8 | setup('authenticate', async ({ page }) => {
9 | const testEmail = `test-${getUnixTime(new Date())}@playwright.com`;
10 | const testPassword = generateId(16);
11 |
12 | await page.goto('http://localhost:3000/register');
13 | await page.getByPlaceholder('user@acme.com').click();
14 | await page.getByPlaceholder('user@acme.com').fill(testEmail);
15 | await page.getByLabel('Password').click();
16 | await page.getByLabel('Password').fill(testPassword);
17 | await page.getByRole('button', { name: 'Sign Up' }).click();
18 |
19 | await expect(page.getByTestId('toast')).toContainText(
20 | 'Account created successfully!',
21 | );
22 |
23 | await page.context().storageState({ path: authFile });
24 | });
25 |
--------------------------------------------------------------------------------
/frontend/tests/reasoning.setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { expect, test as setup } from '@playwright/test';
3 | import { ChatPage } from './pages/chat';
4 |
5 | const reasoningFile = path.join(
6 | __dirname,
7 | '../playwright/.reasoning/session.json',
8 | );
9 |
10 | setup('switch to reasoning model', async ({ page }) => {
11 | const chatPage = new ChatPage(page);
12 | await chatPage.createNewChat();
13 |
14 | await chatPage.chooseModelFromSelector('chat-model-reasoning');
15 |
16 | await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model');
17 |
18 | await page.waitForTimeout(1000);
19 | await page.context().storageState({ path: reasoningFile });
20 | });
21 |
--------------------------------------------------------------------------------
/frontend/tests/reasoning.test.ts:
--------------------------------------------------------------------------------
1 | import { ChatPage } from './pages/chat';
2 | import { test, expect } from '@playwright/test';
3 |
4 | test.describe('chat activity with reasoning', () => {
5 | let chatPage: ChatPage;
6 |
7 | test.beforeEach(async ({ page }) => {
8 | chatPage = new ChatPage(page);
9 | await chatPage.createNewChat();
10 | });
11 |
12 | test('send user message and generate response with reasoning', async () => {
13 | await chatPage.sendUserMessage('Why is the sky blue?');
14 | await chatPage.isGenerationComplete();
15 |
16 | const assistantMessage = await chatPage.getRecentAssistantMessage();
17 | expect(assistantMessage.content).toBe("It's just blue duh!");
18 |
19 | expect(assistantMessage.reasoning).toBe(
20 | 'The sky is blue because of rayleigh scattering!',
21 | );
22 | });
23 |
24 | test('toggle reasoning visibility', async () => {
25 | await chatPage.sendUserMessage('Why is the sky blue?');
26 | await chatPage.isGenerationComplete();
27 |
28 | const assistantMessage = await chatPage.getRecentAssistantMessage();
29 | const reasoningElement =
30 | assistantMessage.element.getByTestId('message-reasoning');
31 | expect(reasoningElement).toBeVisible();
32 |
33 | await assistantMessage.toggleReasoningVisibility();
34 | await expect(reasoningElement).not.toBeVisible();
35 |
36 | await assistantMessage.toggleReasoningVisibility();
37 | await expect(reasoningElement).toBeVisible();
38 | });
39 |
40 | test('edit message and resubmit', async () => {
41 | await chatPage.sendUserMessage('Why is the sky blue?');
42 | await chatPage.isGenerationComplete();
43 |
44 | const assistantMessage = await chatPage.getRecentAssistantMessage();
45 | const reasoningElement =
46 | assistantMessage.element.getByTestId('message-reasoning');
47 | expect(reasoningElement).toBeVisible();
48 |
49 | const userMessage = await chatPage.getRecentUserMessage();
50 |
51 | await userMessage.edit('Why is grass green?');
52 | await chatPage.isGenerationComplete();
53 |
54 | const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
55 |
56 | expect(updatedAssistantMessage.content).toBe("It's just green duh!");
57 |
58 | expect(updatedAssistantMessage.reasoning).toBe(
59 | 'Grass is green because of chlorophyll absorption!',
60 | );
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "baseUrl": ".",
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | ".next/types/**/*.ts",
31 | "next.config.ts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "[start.sh] Starting script..."
4 |
5 | # Start backend first
6 | echo "[start.sh] Starting backend server..."
7 | cd /app/backend
8 | echo "[start.sh] Current directory: $(pwd)"
9 | echo "[start.sh] Listing directory contents:"
10 | ls -la
11 | poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 &
12 |
13 | # Start frontend
14 | echo "[start.sh] Starting frontend server..."
15 | cd /app/frontend
16 | echo "[start.sh] Current directory: $(pwd)"
17 | echo "[start.sh] Listing directory contents:"
18 | ls -la
19 | pnpm run start
20 |
--------------------------------------------------------------------------------