├── .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 | 26 | 27 | 28 | 29 | Navigation 30 | 31 | 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 | 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 |