diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 0197c0ff..631b3004 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,7 +1,4 @@
contact_links:
- - name: 💡 Feature Request
- url: https://github.com/sourcebot-dev/sourcebot/discussions/new?category=ideas
- about: Suggest any ideas you have using our discussion forums.
- - name: 🛟 Get Help
- url: https://github.com/sourcebot-dev/sourcebot/discussions/new?category=support
- about: If you can't get something to work the way you expect, open a question in our discussion forums.
\ No newline at end of file
+ - name: 👾 Discord
+ url: https://discord.gg/f4Cbf3HT
+ about: Something else? Join the Discord!
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..4832e46d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,12 @@
+---
+name: "💡 Feature Request"
+about: Suggest an idea for this project
+title: "[FR] "
+labels: enhancement
+assignees: ''
+
+---
+
+
+
+
diff --git a/.github/ISSUE_TEMPLATE/get_help.md b/.github/ISSUE_TEMPLATE/get_help.md
new file mode 100644
index 00000000..95693738
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/get_help.md
@@ -0,0 +1,12 @@
+---
+name: "🛟 Get Help"
+about: Something isn't working the way you expect
+title: ""
+labels: help wanted
+assignees: ''
+
+---
+
+
+
+
diff --git a/.github/workflows/_gcp-deploy.yml b/.github/workflows/_gcp-deploy.yml
index cee812a4..15fde89b 100644
--- a/.github/workflows/_gcp-deploy.yml
+++ b/.github/workflows/_gcp-deploy.yml
@@ -60,6 +60,8 @@ jobs:
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }}
NEXT_PUBLIC_SENTRY_WEBAPP_DSN=${{ vars.NEXT_PUBLIC_SENTRY_WEBAPP_DSN }}
NEXT_PUBLIC_SENTRY_BACKEND_DSN=${{ vars.NEXT_PUBLIC_SENTRY_BACKEND_DSN }}
+ NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=${{ vars.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY }}
+ NEXT_PUBLIC_LANGFUSE_BASE_URL=${{ vars.NEXT_PUBLIC_LANGFUSE_BASE_URL }}
SENTRY_SMUAT=${{ secrets.SENTRY_SMUAT }}
SENTRY_ORG=${{ vars.SENTRY_ORG }}
SENTRY_WEBAPP_PROJECT=${{ vars.SENTRY_WEBAPP_PROJECT }}
diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml
index 50884bef..71bc9ef0 100644
--- a/.github/workflows/deploy-demo.yml
+++ b/.github/workflows/deploy-demo.yml
@@ -2,7 +2,7 @@ name: Deploy Demo
on:
push:
- tags: ["v*.*.*"]
+ branches: ["main"]
workflow_dispatch:
jobs:
diff --git a/.github/workflows/update-roadmap-released.yml b/.github/workflows/update-roadmap-released.yml
new file mode 100644
index 00000000..b40955a1
--- /dev/null
+++ b/.github/workflows/update-roadmap-released.yml
@@ -0,0 +1,76 @@
+name: Update Roadmap Released
+
+on:
+ pull_request:
+ types: [closed]
+ workflow_dispatch:
+ schedule:
+ - cron: "0 */6 * * *"
+
+permissions:
+ pull-requests: read
+ contents: read
+ issues: write
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Update "Released" section with last 10 merged PRs
+ uses: actions/github-script@v7
+ env:
+ ROADMAP_ISSUE_NUMBER: "459"
+ with:
+ script: |
+ const issue_number = parseInt(process.env.ROADMAP_ISSUE_NUMBER, 10);
+ const {owner, repo} = context.repo;
+
+ // Fetch more than 10, then sort by closed_at to be precise
+ const batchSize = 50;
+ const { data: prBatch } = await github.rest.pulls.list({
+ owner,
+ repo,
+ state: "closed",
+ per_page: batchSize,
+ sort: "updated",
+ direction: "desc"
+ });
+
+ const last10 = prBatch
+ .filter(pr => pr.merged_at) // only merged PRs
+ .sort((a, b) => new Date(b.merged_at) - new Date(a.merged_at))
+ .slice(0, 10);
+
+ const list = last10.map(pr => `- #${pr.number}`).join("\n");
+
+ const start = "";
+ const end = "";
+
+ const mergedUrl = `https://github.com/${owner}/${repo}/pulls?q=is%3Apr+is%3Amerged`;
+ const replacementBlock = [
+ start,
+ "",
+ `10 most recent [merged PRs](${mergedUrl}):`,
+ "",
+ list,
+ "",
+ end
+ ].join("\n");
+
+ const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
+ let body = issue.body || "";
+
+ if (body.includes(start) && body.includes(end)) {
+ const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
+ body = body.replace(pattern, replacementBlock);
+ } else {
+ core.setFailed('Missing RELEASED markers in roadmap issue body. Please add and to the issue.');
+ return;
+ }
+
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number,
+ body
+ });
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 169b2e6a..89530b9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,144 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added support for passing db connection url as seperate `DATABASE_HOST`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`, and `DATABASE_ARGS` env vars. [#545](https://github.com/sourcebot-dev/sourcebot/pull/545)
+
+## [4.7.3] - 2025-09-29
+
+### Fixed
+- Manually pass auth token for ado server deployments. [#543](https://github.com/sourcebot-dev/sourcebot/pull/543)
+
+## [4.7.2] - 2025-09-22
+
+### Fixed
+- Fix support email. [#529](https://github.com/sourcebot-dev/sourcebot/pull/529)
+
+### Added
+- [Experimental][Sourcebot EE] Added permission syncing repository Access Control Lists (ACLs) between Sourcebot and GitHub. [#508](https://github.com/sourcebot-dev/sourcebot/pull/508)
+
+### Changed
+- Improved repository query performance by adding db indices. [#526](https://github.com/sourcebot-dev/sourcebot/pull/526)
+- Improved repository query performance by removing JOIN on `Connection` table. [#527](https://github.com/sourcebot-dev/sourcebot/pull/527)
+- Changed repo carousel and repo list links to redirect to the file browser. [#528](https://github.com/sourcebot-dev/sourcebot/pull/528)
+- Changed file headers, files/directories in file tree, and reference list buttons into links. [#532](https://github.com/sourcebot-dev/sourcebot/pull/532)
+
+## [4.7.1] - 2025-09-19
+
+### Fixed
+- Fixed sourcebot not pulling github forked repos [#499](https://github.com/sourcebot-dev/sourcebot/pull/499)
+- Fixed azure devop cloud pat issue [#524](https://github.com/sourcebot-dev/sourcebot/pull/524)
+
+## [4.7.0] - 2025-09-17
+
+### Added
+- Added fallback to default the Node.JS AWS SDK's `fromNodeProviderChain` when no credentials are provided for a bedrock config. [#513](https://github.com/sourcebot-dev/sourcebot/pull/513)
+- Added support for Azure Devops support. [#514](https://github.com/sourcebot-dev/sourcebot/pull/514)
+
+### Fixed
+- Fixed "At least one project, user, or group must be specified" for GitLab configs with `all` in web configurator. [#512](https://github.com/sourcebot-dev/sourcebot/pull/512)
+- Fixed zoekt indexing failing with pipe in branch/tag names [#506](https://github.com/sourcebot-dev/sourcebot/pull/506)
+- Removed deprecated connection creation/edit UI [#515](https://github.com/sourcebot-dev/sourcebot/pull/515)
+
+## [4.6.8] - 2025-09-15
+
+### Fixed
+- Fixed Bitbucket Cloud pagination not working beyond first page. [#295](https://github.com/sourcebot-dev/sourcebot/issues/295)
+- Fixed search bar line wrapping. [#501](https://github.com/sourcebot-dev/sourcebot/pull/501)
+- Fixed carousel perf issues. [#507](https://github.com/sourcebot-dev/sourcebot/pull/507)
+
+## [4.6.7] - 2025-09-08
+
+### Added
+- Added `exclude.userOwnedProjects` setting to GitLab configs. [#498](https://github.com/sourcebot-dev/sourcebot/pull/498)
+
+### Fixed
+- Fixed "couldn't find remote ref HEAD" errors when re-indexing certain repositories. [#497](https://github.com/sourcebot-dev/sourcebot/pull/497)
+
+### Changed
+- Disable page scroll when using arrow keys on search suggestions box. [#493](https://github.com/sourcebot-dev/sourcebot/pull/493)
+
+## [4.6.6] - 2025-09-04
+
+### Added
+- Added support for specifying query params for openai compatible language models. [#490](https://github.com/sourcebot-dev/sourcebot/pull/490)
+
+### Fixed
+- Fix issue where zoekt was failing to index repositories due to `HEAD` pointing to a branch that does not exist. [#488](https://github.com/sourcebot-dev/sourcebot/pull/488)
+
+## [4.6.5] - 2025-09-02
+
+### Fixed
+- Remove setting `remote.origin.url` for remote git repositories. [#483](https://github.com/sourcebot-dev/sourcebot/pull/483)
+- Fix error when navigating to paths with percentage symbols. [#485](https://github.com/sourcebot-dev/sourcebot/pull/485)
+
+### Changed
+- Updated NextJS to version 15. [#477](https://github.com/sourcebot-dev/sourcebot/pull/477)
+- Add `sessionToken` as optional Bedrock configuration parameter. [#478](https://github.com/sourcebot-dev/sourcebot/pull/478)
+
+## [4.6.4] - 2025-08-11
+
+### Added
+- Added multi-branch indexing support for Gerrit. [#433](https://github.com/sourcebot-dev/sourcebot/pull/433)
+- [ask sb] Added `reasoningEffort` option to OpenAI provider. [#446](https://github.com/sourcebot-dev/sourcebot/pull/446)
+- [ask db] Added `headers` option to all providers. [#449](https://github.com/sourcebot-dev/sourcebot/pull/449)
+
+### Fixed
+- Removed prefix from structured log output. [#443](https://github.com/sourcebot-dev/sourcebot/pull/443)
+- [ask sb] Fixed long generation times for first message in a chat thread. [#447](https://github.com/sourcebot-dev/sourcebot/pull/447)
+
+### Changed
+- Bumped AI SDK and associated packages version. [#444](https://github.com/sourcebot-dev/sourcebot/pull/444)
+
+## [4.6.3] - 2025-08-04
+
+### Fixed
+- Fixed issue where `users` specified in a GitHub config were not getting picked up when a `token` is also specified. [#428](https://github.com/sourcebot-dev/sourcebot/pull/428)
+
+### Added
+- [ask sb] Added OpenAI Compatible Language Provider. [#424](https://github.com/sourcebot-dev/sourcebot/pull/424)
+
+## [4.6.2] - 2025-07-31
+
+### Changed
+- Bumped AI SDK and associated packages version. [#417](https://github.com/sourcebot-dev/sourcebot/pull/417)
+
+### Fixed
+- [ask sb] Fixed "413 content too large" error when starting a new chat with many repos selected. [#416](https://github.com/sourcebot-dev/sourcebot/pull/416)
+
+### Added
+- [ask sb] PostHog telemetry for chat thread creation. [#418](https://github.com/sourcebot-dev/sourcebot/pull/418)
+
+## [4.6.1] - 2025-07-29
+
+### Added
+- Add search context to ask sourcebot context selector. [#397](https://github.com/sourcebot-dev/sourcebot/pull/397)
+- Add ability to include/exclude connection in search context. [#399](https://github.com/sourcebot-dev/sourcebot/pull/399)
+- Search context refactor to search scope and demo card UI changes. [#405](https://github.com/sourcebot-dev/sourcebot/pull/405)
+- Add GitHub star toast. [#409](https://github.com/sourcebot-dev/sourcebot/pull/409)
+- Added a onboarding modal when first visiting the homepage when `ask` mode is selected. [#408](https://github.com/sourcebot-dev/sourcebot/pull/408)
+- [ask sb] Added `searchReposTool` and `listAllReposTool`. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
+
+### Fixed
+- Fixed multiple writes race condition on config file watcher. [#398](https://github.com/sourcebot-dev/sourcebot/pull/398)
+
+### Changed
+- Bumped AI SDK and associated packages version. [#404](https://github.com/sourcebot-dev/sourcebot/pull/404)
+- Bumped form-data package version. [#407](https://github.com/sourcebot-dev/sourcebot/pull/407)
+- Bumped next version. [#406](https://github.com/sourcebot-dev/sourcebot/pull/406)
+- [ask sb] Improved search code tool with filter options. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
+- [ask sb] Removed search scope constraint. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
+- Update README with new features and videos. [#410](https://github.com/sourcebot-dev/sourcebot/pull/410)
+- [ask sb] Add back search scope requirement and other UI changes. [#411](https://github.com/sourcebot-dev/sourcebot/pull/411)
+
+## [4.6.0] - 2025-07-25
+
+### Added
+- Introducing Ask Sourcebot - ask natural langauge about your codebase. Get back comprehensive Markdown responses with inline citations back to the code. Bring your own LLM api key. [#392](https://github.com/sourcebot-dev/sourcebot/pull/392)
+
+### Fixed
+- Fixed onboarding infinite loop when GCP IAP Auth is enabled. [#381](https://github.com/sourcebot-dev/sourcebot/pull/381)
+
## [4.5.3] - 2025-07-20
### Changed
@@ -223,7 +361,7 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou
### Removed
- [**Breaking Change**] Removed `db.json` in favour of a Postgres database for transactional workloads. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/overview#architecture).
-- [**Breaking Change**] Removed local folder & arbitrary .git repo support. If your deployment depended on these features, please [open a discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and let us know.
+- [**Breaking Change**] Removed local folder & arbitrary .git repo support. If your deployment depended on these features, please [open a issue](https://github.com/sourcebot-dev/sourcebot/issues/new?template=get_help.md) and let us know.
- [**Breaking Chnage**] Removed ability to specify a `token` as a string literal from the schema.
- [**Breaking Change**] Removed support for `DOMAIN_SUB_PATH` configuration.
diff --git a/Dockerfile b/Dockerfile
index 24f542b1..010f5940 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,6 +17,8 @@ ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
ARG NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT
ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN
ARG NEXT_PUBLIC_SENTRY_BACKEND_DSN
+ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY
+ARG NEXT_PUBLIC_LANGFUSE_BASE_URL
FROM node:20-alpine3.19 AS node-alpine
FROM golang:1.23.4-alpine3.19 AS go-alpine
@@ -67,6 +69,10 @@ ARG NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT
ENV NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT=$NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT
ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN
ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=$NEXT_PUBLIC_SENTRY_WEBAPP_DSN
+ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY
+ENV NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=$NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY
+ARG NEXT_PUBLIC_LANGFUSE_BASE_URL
+ENV NEXT_PUBLIC_LANGFUSE_BASE_URL=$NEXT_PUBLIC_LANGFUSE_BASE_URL
# To upload source maps to Sentry, we need to set the following build-time args.
# It's important that we don't set these for oss builds, otherwise the Sentry
@@ -164,6 +170,10 @@ ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN
ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=$NEXT_PUBLIC_SENTRY_WEBAPP_DSN
ARG NEXT_PUBLIC_SENTRY_BACKEND_DSN
ENV NEXT_PUBLIC_SENTRY_BACKEND_DSN=$NEXT_PUBLIC_SENTRY_BACKEND_DSN
+ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY
+ENV NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=$NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY
+ARG NEXT_PUBLIC_LANGFUSE_BASE_URL
+ENV NEXT_PUBLIC_LANGFUSE_BASE_URL=$NEXT_PUBLIC_LANGFUSE_BASE_URL
# -----------
RUN echo "Sourcebot Version: $NEXT_PUBLIC_SOURCEBOT_VERSION"
@@ -175,7 +185,6 @@ ENV DATA_DIR=/data
ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot
ENV DATABASE_DATA_DIR=$DATA_CACHE_DIR/db
ENV REDIS_DATA_DIR=$DATA_CACHE_DIR/redis
-ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
ENV REDIS_URL="redis://localhost:6379"
ENV SRC_TENANT_ENFORCEMENT_MODE=strict
ENV SOURCEBOT_PUBLIC_KEY_PATH=/app/public.pem
diff --git a/LICENSE.md b/LICENSE.md
index 93c14254..315bde81 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -2,7 +2,7 @@ Copyright (c) 2025 Taqla Inc.
Portions of this software are licensed as follows:
-- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
+- All content that resides under the "ee/", "packages/web/src/ee/", "packages/backend/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "Functional Source License" as defined below.
diff --git a/README.md b/README.md
index 40193362..de9b664f 100644
--- a/README.md
+++ b/README.md
@@ -12,22 +12,19 @@
Self Host
·
- Demo
+ Public Demo
- Sourcebot uses Github Discussions for Support and Feature Requests.
-
-
@@ -37,30 +34,45 @@
-
-
-
-# About
+Sourcebot is a self-hosted tool that helps you understand your codebase.
-Sourcebot lets you index all your repos and branches across multiple code hosts (GitHub, GitLab, Bitbucket, Gitea, or Gerrit) and search through them using a blazingly fast interface.
+- **Ask Sourcebot:** Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations.
+- **Code search:** Search and navigate across all your repos and branches, no matter where they’re hosted.
-https://github.com/user-attachments/assets/ced355f3-967e-4f37-ae6e-74ab8c06b9ec
+Try it out in our [public demo](https://demo.sourcebot.dev)!
+https://github.com/user-attachments/assets/ed66a622-e38f-4947-a531-86df1e1e0218
-## Features
-- 💻 **One-command deployment**: Get started instantly using Docker on your own machine.
-- 🔍 **Multi-repo search**: Index and search through multiple public and private repositories and branches on GitHub, GitLab, Bitbucket, Gitea, or Gerrit.
-- ⚡**Lightning fast performance**: Built on top of the powerful [Zoekt](https://github.com/sourcegraph/zoekt) search engine.
-- 🎨 **Modern web app**: Enjoy a sleek interface with features like syntax highlighting, light/dark mode, and vim-style navigation
-- 📂 **Full file visualization**: Instantly view the entire file when selecting any search result.
+# Features
+
-You can try out our public hosted demo [here](https://demo.sourcebot.dev)!
+## Ask Sourcebot
+Ask Sourcebot gives you the ability to ask complex questions about your codebase in natural language.
+
+It uses Sourcebot's existing code search and navigation tools to allow reasoning models to search your code, follow code nav references, and provide an answer that's rich with inline citations and navigable code snippets.
+
+https://github.com/user-attachments/assets/8212cd16-683f-468f-8ea5-67455c0931e2
+
+## Code Search
+Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more.
+
+https://github.com/user-attachments/assets/3b381452-d329-4949-b6f2-2fc38952e481
+
+## Code Navigation
+IDE-level code navigation (goto definition and find references) across all your repos.
+
+https://github.com/user-attachments/assets/e2da2829-71cc-40af-98b4-7ba52e945530
+
+## Built-in File Explorer
+Explore every file across all of your repos. Modern UI with syntax highlighting, file tree, code navigation, etc.
+
+https://github.com/user-attachments/assets/31ec0669-707d-4e03-b511-1bc33d44197a
# Deploy Sourcebot
-Sourcebot can be deployed in seconds using our official docker image. Visit our [docs](https://docs.sourcebot.dev/self-hosting/overview) for more information.
+Sourcebot can be deployed in seconds using our official docker image. Visit our [docs](https://docs.sourcebot.dev/docs/deployment-guide) for more information.
1. Create a config
```sh
@@ -102,10 +114,10 @@ docker run \
-3. Start searching at `http://localhost:3000`
+3. Visit `http://localhost:3000` to start using Sourcebot
-To learn how to configure Sourcebot to index your own repos, please refer to our [docs](https://docs.sourcebot.dev/self-hosting/overview).
+To configure Sourcebot (index your own repos, connect your LLMs, etc), check out our [docs](https://docs.sourcebot.dev/docs/configuration/config-file).
> [!NOTE]
> Sourcebot collects anonymous usage data by default to help us improve the product. No sensitive data is collected, but if you'd like to disable this you can do so by setting the `SOURCEBOT_TELEMETRY_DISABLED` environment
diff --git a/docs/docs.json b/docs/docs.json
index e0834f28..3a2cb43f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -28,16 +28,25 @@
"group": "Features",
"pages": [
{
- "group": "Search",
+ "group": "Code Search",
"pages": [
+ "docs/features/search/overview",
"docs/features/search/syntax-reference",
"docs/features/search/multi-branch-indexing",
"docs/features/search/search-contexts"
]
},
+ {
+ "group": "Ask Sourcebot",
+ "pages": [
+ "docs/features/ask/overview",
+ "docs/features/ask/add-model-providers"
+ ]
+ },
"docs/features/code-navigation",
"docs/features/analytics",
"docs/features/mcp-server",
+ "docs/features/permission-syncing",
{
"group": "Agents",
"tag": "experimental",
@@ -51,6 +60,7 @@
{
"group": "Configuration",
"pages": [
+ "docs/configuration/config-file",
{
"group": "Indexing your code",
"pages": [
@@ -59,6 +69,8 @@
"docs/connections/gitlab",
"docs/connections/bitbucket-cloud",
"docs/connections/bitbucket-data-center",
+ "docs/connections/ado-cloud",
+ "docs/connections/ado-server",
"docs/connections/gitea",
"docs/connections/gerrit",
"docs/connections/generic-git-host",
@@ -66,8 +78,7 @@
"docs/connections/request-new"
]
},
- "docs/license-key",
- "docs/configuration/environment-variables",
+ "docs/configuration/language-model-providers",
{
"group": "Authentication",
"pages": [
@@ -78,6 +89,8 @@
"docs/configuration/auth/faq"
]
},
+ "docs/configuration/environment-variables",
+ "docs/license-key",
"docs/configuration/transactional-emails",
"docs/configuration/structured-logging",
"docs/configuration/audit-logs"
@@ -97,9 +110,14 @@
"href": "https://sourcebot.dev/changelog",
"icon": "list-check"
},
+ {
+ "anchor": "Roadmap",
+ "href": "https://github.com/sourcebot-dev/sourcebot/issues/459",
+ "icon": "map"
+ },
{
"anchor": "Support",
- "href": "https://github.com/sourcebot-dev/sourcebot/discussions/categories/support",
+ "href": "https://github.com/sourcebot-dev/sourcebot/issues/new?template=get_help.md",
"icon": "life-ring"
}
]
diff --git a/docs/docs/configuration/auth/faq.mdx b/docs/docs/configuration/auth/faq.mdx
index 5d37bc66..f7192f4f 100644
--- a/docs/docs/configuration/auth/faq.mdx
+++ b/docs/docs/configuration/auth/faq.mdx
@@ -41,6 +41,4 @@ This page covers a range of frequently asked questions about Sourcebot's built-i
-
-Have a question that's not answered here? Submit it on our [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
-page and we'll get back to you as soon as we can!
\ No newline at end of file
+Have a question that's not answered here? Submit an issue on [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) and we'll get back to you as soon as we can.
\ No newline at end of file
diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/overview.mdx
index 732fef35..2544c013 100644
--- a/docs/docs/configuration/auth/overview.mdx
+++ b/docs/docs/configuration/auth/overview.mdx
@@ -25,4 +25,4 @@ Sourcebot's built-in authentication system gates your deployment, and allows adm
# Troubleshooting
- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers).
-- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
\ No newline at end of file
+- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose)
\ No newline at end of file
diff --git a/docs/docs/configuration/config-file.mdx b/docs/docs/configuration/config-file.mdx
new file mode 100644
index 00000000..58d7a1b1
--- /dev/null
+++ b/docs/docs/configuration/config-file.mdx
@@ -0,0 +1,51 @@
+---
+title: Config File
+sidebarTitle: Config file
+---
+
+When self-hosting Sourcebot, you **must** provide it a config file. This is done by defining a config file in a volume that's mounted to Sourcebot, and providing the path to this
+file in the `CONFIG_PATH` environment variable. For example:
+
+```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot
+docker run \
+ -v $(pwd)/config.json:/data/config.json \
+ -e CONFIG_PATH=/data/config.json \
+ ... \ # other options
+ ghcr.io/sourcebot-dev/sourcebot:latest
+```
+
+The config file tells Sourcebot which repos to index, what language models to use, and various other settings as defined in the [schema](#config-file-schema).
+
+# Config File Schema
+
+The config file you provide Sourcebot must follow the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json). This schema consists of the following properties:
+
+- [Connections](/docs/connections/overview) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where
+- [Language Models](/docs/configuration/language-model-providers) (`models`): Defines a set of language model providers for use with [Ask Sourcebot](/docs/features/ask)
+- [Settings](#settings) (`settings`): Additional settings to tweak your Sourcebot deployment
+- [Search Contexts](/docs/features/search/search-contexts) (`contexts`): Groupings of repos that you can search against
+
+# Config File Syncing
+
+Sourcebot syncs the config file on startup, and automatically whenever a change is detected.
+
+# Settings
+
+The following are settings that can be provided in your config file to modify Sourcebot's behavior
+
+| Setting | Type | Default | Minimum | Description / Notes |
+|-------------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------|
+| `maxFileSize` | number | 2 MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
+| `maxTrigramCount` | number | 20 000 | 1 | Maximum trigrams per document. Larger files are skipped. |
+| `reindexIntervalMs` | number | 1 hour | 1 | Interval at which all repositories are re‑indexed. |
+| `resyncConnectionIntervalMs` | number | 24 hours | 1 | Interval for checking connections that need re‑syncing. |
+| `resyncConnectionPollingIntervalMs` | number | 1 second | 1 | DB polling rate for connections that need re‑syncing. |
+| `reindexRepoPollingIntervalMs` | number | 1 second | 1 | DB polling rate for repos that should be re‑indexed. |
+| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connection‑sync jobs. |
+| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repo‑indexing jobs. |
+| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. |
+| `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. |
+| `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. |
+| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
+| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the repo permission syncer should run. |
+| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the user permission syncer should run. |
diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx
index d6aab9eb..d49073fd 100644
--- a/docs/docs/configuration/environment-variables.mdx
+++ b/docs/docs/configuration/environment-variables.mdx
@@ -19,7 +19,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` | The root data directory in which all data written to disk by Sourcebot will be located.
|
| `DATA_DIR` | `/data` | The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)
|
| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | The data directory for the default Postgres database.
|
-| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.
If you'd like to use a non-default schema, you can provide it as a parameter in the database url
|
+| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.
If you'd like to use a non-default schema, you can provide it as a parameter in the database url.
You can also use `DATABASE_HOST`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`, and `DATABASE_ARGS` to construct the database url.
|
| `EMAIL_FROM_ADDRESS` | `-` | The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.
|
| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` | When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled
| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | The data directory for the default Redis instance.
|
@@ -59,6 +59,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `AUTH_EE_OKTA_ISSUER` | `-` | The issuer URL for Okta SSO authentication.
|
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect
|
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning
|
+| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | Enables [permission syncing](/docs/features/permission-syncing).
|
### Review Agent Environment Variables
diff --git a/docs/docs/configuration/language-model-providers.mdx b/docs/docs/configuration/language-model-providers.mdx
new file mode 100644
index 00000000..606f4d31
--- /dev/null
+++ b/docs/docs/configuration/language-model-providers.mdx
@@ -0,0 +1,373 @@
+---
+title: Language Model Providers
+sidebarTitle: Language model providers
+---
+
+import LanguageModelSchema from '/snippets/schemas/v3/languageModel.schema.mdx'
+
+
+Looking to self-host your own model? Check out the [OpenAI Compatible](#openai-compatible) provider.
+
+
+To use [Ask Sourcebot](/docs/features/ask) you must define at least one Language Model Provider. These providers are defined within the [config file](/docs/configuration/config-file) you
+provide Sourcebot.
+
+
+```json wrap icon="code" Example config with language model provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ // 1. Google Vertex config for Gemini 2.5 Pro
+ {
+ "provider": "google-vertex",
+ "model": "gemini-2.5-pro",
+ "displayName": "Gemini 2.5 Pro",
+ "project": "sourcebot",
+ "credentials": {
+ "env": "GOOGLE_APPLICATION_CREDENTIALS"
+ }
+ },
+ // 2. OpenAI config for o3
+ {
+ "provider": "openai",
+ "model": "o3",
+ "displayName": "o3",
+ "token": {
+ "env": "OPENAI_API_KEY"
+ }
+ }
+ ]
+}
+```
+
+# Supported Providers
+
+Sourcebot uses the [Vercel AI SDK](https://ai-sdk.dev/docs/introduction), so it can integrate with any provider the SDK supports. If you don't see your provider below please submit
+a [feature request](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md).
+
+For a detailed description of all the providers, please refer to the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/languageModel.json).
+
+Any parameter defined using `env` will read the value from the corresponding environment variable you provide Sourcebot
+
+### Amazon Bedrock
+
+[Vercel AI SDK Amazon Bedrock Docs](https://ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock)
+
+```json wrap icon="code" Example config with Amazon Bedrock provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "amazon-bedrock",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "accessKeyId": {
+ "env": "AWS_ACCESS_KEY_ID"
+ },
+ "accessKeySecret": {
+ "env": "AWS_SECRET_ACCESS_KEY"
+ },
+ "sessionToken": {
+ "env": "AWS_SESSION_TOKEN"
+ },
+ "region": "YOUR_REGION_HERE", // defaults to the AWS_REGION env var if not set
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Anthropic
+
+[Vercel AI SDK Anthropic Docs](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic)
+
+```json wrap icon="code" Example config with Anthropic provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "anthropic",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "ANTHROPIC_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Azure OpenAI
+
+[Vercel AI SDK Azure OpenAI Docs](https://ai-sdk.dev/providers/ai-sdk-providers/azure)
+
+```json wrap icon="code" Example config with Azure AI provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "azure",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "resourceName": "YOUR_RESOURCE_NAME", // defaults to the AZURE_RESOURCE_NAME env var if not set
+ "apiVersion": "OPTIONAL_API_VERSION", // defailts to 'preview' if not set
+ "token": {
+ "env": "AZURE_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Deepseek
+
+[Vercel AI SDK Deepseek Docs](https://ai-sdk.dev/providers/ai-sdk-providers/deepseek)
+
+```json wrap icon="code" Example config with Deepseek provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "deepseek",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "DEEPSEEK_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Google Generative AI
+
+[Vercel AI SDK Google Generative AI Docs](https://ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai)
+
+```json wrap icon="code" Example config with Google Generative AI provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "google-generative-ai",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "GOOGLE_GENERATIVE_AI_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Google Vertex
+
+If you're using an Anthropic model on Google Vertex, you must define a [Google Vertex Anthropic](#google-vertex-anthropic) provider instead
+The `credentials` paramater here expects a **path** to a [credentials](https://console.cloud.google.com/apis/credentials) file. This file **must be in a volume mounted by Sourcebot** for it to be readable.
+
+[Vercel AI SDK Google Vertex AI Docs](https://ai-sdk.dev/providers/ai-sdk-providers/google-vertex)
+
+```json wrap icon="code" Example config with Google Vertex provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "google-vertex",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "project": "YOUR_PROJECT_ID", // defaults to the GOOGLE_VERTEX_PROJECT env var if not set
+ "region": "YOUR_REGION_HERE", // defaults to the GOOGLE_VERTEX_REGION env var if not set
+ "credentials": {
+ "env": "GOOGLE_APPLICATION_CREDENTIALS"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Google Vertex Anthropic
+
+The `credentials` paramater here expects a **path** to a [credentials](https://console.cloud.google.com/apis/credentials) file. This file **must be in a volume mounted by Sourcebot** for it to be readable.
+
+
+[Vercel AI SDK Google Vertex Anthropic Docs](https://ai-sdk.dev/providers/ai-sdk-providers/google-vertex#google-vertex-anthropic-provider-usage)
+
+```json wrap icon="code" Example config with Google Vertex Anthropic provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "google-vertex-anthropic",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "project": "YOUR_PROJECT_ID", // defaults to the GOOGLE_VERTEX_PROJECT env var if not set
+ "region": "YOUR_REGION_HERE", // defaults to the GOOGLE_VERTEX_REGION env var if not set
+ "credentials": {
+ "env": "GOOGLE_APPLICATION_CREDENTIALS"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### Mistral
+
+[Vercel AI SDK Mistral Docs](https://ai-sdk.dev/providers/ai-sdk-providers/mistral)
+
+```json wrap icon="code" Example config with Mistral provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "mistral",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "MISTRAL_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### OpenAI
+
+[Vercel AI SDK OpenAI Docs](https://ai-sdk.dev/providers/ai-sdk-providers/openai)
+
+```json wrap icon="code" Example config with OpenAI provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "openai",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "OPENAI_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL",
+ "reasoningEffort": "OPTIONAL_REASONING_EFFORT" // defaults to "medium"
+ }
+ ]
+}
+```
+
+### OpenAI Compatible
+
+[Vercel AI SDK OpenAI Compatible Docs](https://ai-sdk.dev/providers/openai-compatible-providers)
+
+The OpenAI compatible provider allows you to use any model that is compatible with the OpenAI [Chat Completions API](https://github.com/ollama/ollama/blob/main/docs/openai.md). This includes self-hosted tools like [Ollama](https://ollama.ai/) and [llama.cpp](https://github.com/ggerganov/llama.cpp).
+
+```json wrap icon="code" Example config with OpenAI Compatible provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "openai-compatible",
+ "baseUrl": "BASE_URL_HERE",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "OPTIONAL_API_KEY"
+ },
+ // Optional query parameters can be passed in the request url as:
+ "queryParams": {
+ // raw string values
+ "optional-query-param": "foo",
+ // or as environment variables
+ "optional-query-param-secret": {
+ "env": "MY_SECRET_ENV_VAR"
+ }
+ }
+ }
+ ]
+}
+```
+
+
+- When using [llama.cpp](https://github.com/ggml-org/llama.cpp), if you hit "Failed after 3 attempts. Last error: tools param requires --jinja flag", add the `--jinja` flag to your `llama-server` command.
+
+
+### OpenRouter
+
+[Vercel AI SDK OpenRouter Docs](https://ai-sdk.dev/providers/community-providers/openrouter)
+
+```json wrap icon="code" Example config with OpenRouter provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "openai",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "OPENROUTER_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+### xAI
+
+[Vercel AI SDK xAI Docs](https://ai-sdk.dev/providers/ai-sdk-providers/xai)
+
+```json wrap icon="code" Example config with xAI provider
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ "provider": "xai",
+ "model": "YOUR_MODEL_HERE",
+ "displayName": "OPTIONAL_DISPLAY_NAME",
+ "token": {
+ "env": "XAI_API_KEY"
+ },
+ "baseUrl": "OPTIONAL_BASE_URL"
+ }
+ ]
+}
+```
+
+# Custom headers
+
+You can pass custom headers to the language model provider by using the `headers` parameter. Header values can either be a string or a environment variable. Headers are supported for all providers.
+
+```json wrap icon="code" Example config with custom headers
+{
+ "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
+ "models": [
+ {
+ // ... provider, model, displayName, etc...
+
+ // Key-value pairs of headers
+ "headers": {
+ // Header values can be passed as a environment variable...
+ "my-secret-header": {
+ "env": "MY_SECRET_HEADER_ENV_VAR"
+ },
+
+ // ... or directly as a string.
+ "my-non-secret-header": "plaintextvalue"
+ }
+ }
+ ]
+}
+```
+
+
+# Schema reference
+
+
+[schemas/v3/languageModel.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/languageModel.json)
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/connections/ado-cloud.mdx b/docs/docs/connections/ado-cloud.mdx
new file mode 100644
index 00000000..cc20c745
--- /dev/null
+++ b/docs/docs/connections/ado-cloud.mdx
@@ -0,0 +1,147 @@
+---
+title: Linking code from Azure Devops Cloud
+sidebarTitle: Azure Devops Cloud
+icon: https://www.svgrepo.com/show/448307/azure-devops.svg
+---
+
+import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx'
+
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
+## Examples
+
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ "repos": [
+ "organizationName/projectName/repoName",
+ "organizationName/projectName/repoName2
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ "orgs": [
+ "organizationName",
+ "organizationName2
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ "projects": [
+ "organizationName/projectName",
+ "organizationName/projectName2"
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ // Include all repos in my-org...
+ "orgs": [
+ "my-org"
+ ],
+ // ...except:
+ "exclude": {
+ // repos that are disabled
+ "disabled": true,
+ // repos that match these glob patterns
+ "repos": [
+ "reposToExclude*"
+ ],
+ // projects that match these glob patterns
+ "projects": [
+ "projectstoExclude*"
+ ]
+ // repos less than the defined min OR larger than the defined max
+ "size": {
+ // repos that are less than 1MB (in bytes)...
+ "min": 1048576,
+ // or repos greater than 100MB (in bytes)
+ "max": 104857600
+ }
+ }
+ }
+ ```
+
+
+
+## Authenticating with Azure Devops Cloud
+
+Azure Devops Cloud requires you to provide a PAT in order to index your repositories. To learn how to create PAT, check out the [Azure Devops docs](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows).
+Sourcebot needs the `Read` access for the `Code` scope in order to find and clone your repos.
+
+Next, provide the access token via the `token` property, either as an environment variable or a secret:
+
+
+
+
+ 1. Add the `token` property to your connection config:
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ "token": {
+ // note: this env var can be named anything. It
+ // doesn't need to be `ADO_TOKEN`.
+ "env": "ADO_TOKEN"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+ 2. Pass this environment variable each time you run Sourcebot:
+ ```bash
+ docker run \
+ -e ADO_TOKEN= \
+ /* additional args */ \
+ ghcr.io/sourcebot-dev/sourcebot:latest
+ ```
+
+
+
+ Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled.
+
+ 1. Navigate to **Secrets** in settings and create a new secret with your PAT:
+
+ 
+
+ 2. Add the `token` property to your connection config:
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "cloud",
+ "token": {
+ "secret": "mysecret"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+
+
+
+## Schema reference
+
+
+[schemas/v3/azuredevops.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/azuredevops.json)
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/connections/ado-server.mdx b/docs/docs/connections/ado-server.mdx
new file mode 100644
index 00000000..1cfc0252
--- /dev/null
+++ b/docs/docs/connections/ado-server.mdx
@@ -0,0 +1,161 @@
+---
+title: Linking code from Azure Devops Server
+sidebarTitle: Azure Devops Server
+icon: https://www.svgrepo.com/show/448307/azure-devops.svg
+---
+
+import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx'
+
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
+## Examples
+
+
+
+ This is required if you're using an older version of ADO Server which has `/tfs` in the repo paths.
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "useTfsPath": true,
+ "repos": [
+ "organizationName/projectName/repoName",
+ "organizationName/projectName/repoName2
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "repos": [
+ "organizationName/projectName/repoName",
+ "organizationName/projectName/repoName2
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "orgs": [
+ "collectionName",
+ "collectionName2"
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "projects": [
+ "collectionName/projectName",
+ "collectionName/projectName2"
+ ]
+ }
+ ```
+
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ // Include all repos in my-org...
+ "orgs": [
+ "my-org"
+ ],
+ // ...except:
+ "exclude": {
+ // repos that are disabled
+ "disabled": true,
+ // repos that match these glob patterns
+ "repos": [
+ "reposToExclude*"
+ ],
+ // projects that match these glob patterns
+ "projects": [
+ "projectstoExclude*"
+ ]
+ // repos less than the defined min OR larger than the defined max
+ "size": {
+ // repos that are less than 1MB (in bytes)...
+ "min": 1048576,
+ // or repos greater than 100MB (in bytes)
+ "max": 104857600
+ }
+ }
+ }
+ ```
+
+
+
+## Authenticating with Azure Devops Server
+
+Azure Devops Server requires you to provide a PAT in order to index your repositories. To learn how to create PAT, check out the [Azure Devops docs](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows).
+Sourcebot needs the `Read` access for the `Code` scope in order to find and clone your repos.
+
+Next, provide the access token via the `token` property, either as an environment variable or a secret:
+
+
+
+
+ 1. Add the `token` property to your connection config:
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "token": {
+ // note: this env var can be named anything. It
+ // doesn't need to be `ADO_TOKEN`.
+ "env": "ADO_TOKEN"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+ 2. Pass this environment variable each time you run Sourcebot:
+ ```bash
+ docker run \
+ -e ADO_TOKEN= \
+ /* additional args */ \
+ ghcr.io/sourcebot-dev/sourcebot:latest
+ ```
+
+
+
+ Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled.
+
+ 1. Navigate to **Secrets** in settings and create a new secret with your PAT:
+
+ 
+
+ 2. Add the `token` property to your connection config:
+
+ ```json
+ {
+ "type": "azuredevops",
+ "deploymentType": "server",
+ "token": {
+ "secret": "mysecret"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+
+
+
+## Schema reference
+
+
+[schemas/v3/azuredevops.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/azuredevops.json)
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx
index 0c431ce3..bde7a665 100644
--- a/docs/docs/connections/bitbucket-cloud.mdx
+++ b/docs/docs/connections/bitbucket-cloud.mdx
@@ -12,6 +12,8 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx'
Looking for docs on Bitbucket Data Center? See [this doc](/docs/connections/bitbucket-data-center).
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Examples
diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx
index c76ca383..77479e46 100644
--- a/docs/docs/connections/bitbucket-data-center.mdx
+++ b/docs/docs/connections/bitbucket-data-center.mdx
@@ -12,6 +12,8 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx'
Looking for docs on Bitbucket Cloud? See [this doc](/docs/connections/bitbucket-cloud).
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Examples
diff --git a/docs/docs/connections/generic-git-host.mdx b/docs/docs/connections/generic-git-host.mdx
index df6202b2..c960f248 100644
--- a/docs/docs/connections/generic-git-host.mdx
+++ b/docs/docs/connections/generic-git-host.mdx
@@ -7,6 +7,8 @@ import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx'
Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/overview#supported-code-hosts).
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Getting Started
To connect to a Git host, create a new [connection](/docs/connections/overview) with type `git` and specify the clone url in the `url` property. For example:
diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx
index b7253259..4b312b36 100644
--- a/docs/docs/connections/gerrit.mdx
+++ b/docs/docs/connections/gerrit.mdx
@@ -6,10 +6,12 @@ icon: crow
import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx'
-Authenticating with Gerrit is currently not supported. If you need this capability, please raise a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas).
+Authenticating with Gerrit is currently not supported. If you need this capability, please raise a [feature request](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md).
Sourcebot can sync code from self-hosted gerrit instances.
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Connecting to a Gerrit instance
To connect to a gerrit instance, provide the `url` property to your config:
diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx
index 8869ebff..1d439c76 100644
--- a/docs/docs/connections/gitea.mdx
+++ b/docs/docs/connections/gitea.mdx
@@ -8,6 +8,8 @@ import GiteaSchema from '/snippets/schemas/v3/gitea.schema.mdx'
Sourcebot can sync code from Gitea Cloud, and self-hosted.
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Examples
@@ -83,7 +85,6 @@ Next, provide the access token via the `token` property, either as an environmen
- Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI.
1. Add the `token` property to your connection config:
```json
diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx
index 2f31994e..98fc5b50 100644
--- a/docs/docs/connections/github.mdx
+++ b/docs/docs/connections/github.mdx
@@ -8,6 +8,8 @@ import GitHubSchema from '/snippets/schemas/v3/github.schema.mdx'
Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub Enterprise Cloud.
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Examples
@@ -130,7 +132,6 @@ Next, provide the access token via the `token` property, either as an environmen
- Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI.
1. Add the `token` property to your connection config:
```json
@@ -195,4 +196,8 @@ To connect to a GitHub host other than `github.com`, provide the `url` property
-
\ No newline at end of file
+
+
+## See also
+
+- [Syncing GitHub Access permissions to Sourcebot](/docs/features/permission-syncing#github)
diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx
index 2677a67a..2680064b 100644
--- a/docs/docs/connections/gitlab.mdx
+++ b/docs/docs/connections/gitlab.mdx
@@ -8,6 +8,7 @@ import GitLabSchema from '/snippets/schemas/v3/gitlab.schema.mdx'
Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated.
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
## Examples
@@ -89,6 +90,8 @@ Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated.
"archived": true,
// projects that are forks
"forks": true,
+ // projects that are owned by users (not groups)
+ "userOwnedProjects": true,
// projects that match these glob patterns
"projects": [
"my-group/foo/**",
@@ -117,7 +120,6 @@ Next, provide the PAT via the `token` property, either as an environment variabl
- Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI.
1. Add the `token` property to your connection config:
```json
diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx
index 0791b8f0..114c3c8c 100644
--- a/docs/docs/connections/local-repos.mdx
+++ b/docs/docs/connections/local-repos.mdx
@@ -7,6 +7,8 @@ import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx'
Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions.
+If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
+
## Getting Started
diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx
index 5165a2aa..ab9f8ffc 100644
--- a/docs/docs/connections/overview.mdx
+++ b/docs/docs/connections/overview.mdx
@@ -6,20 +6,7 @@ sidebarTitle: Overview
import SupportedPlatforms from '/snippets/platform-support.mdx'
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
-To index your code with Sourcebot, you must provide a configuration file. When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its
-path specified in the `CONFIG_PATH` environment variable. For example:
-
-```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot
-docker run \
- -v $(pwd)/config.json:/data/config.json \
- -e CONFIG_PATH=/data/config.json \
- ... \ # other config
- ghcr.io/sourcebot-dev/sourcebot:latest
-```
-
-## Config Schema
-
-The configuration file defines a set of **connections**. A connection in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.).
+A **connection** represents Sourcebot's link to a code host platform (GitHub, GitLab, etc). Connections are defined within the [config file](/docs/configuration/config-file) you provide Sourcebot.
Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name.
@@ -55,10 +42,11 @@ Each connection defines how Sourcebot should authenticate and interact with a pa
Configuration files must conform to the [JSON schema](#schema-reference).
-## Config Syncing
-Sourcebot performs syncing in the background. Syncing consists of two steps:
-1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host.
-2. Re-indexes the repository.
+## Connection Syncing
+
+When a connection is first discovered, or the `resyncConnectionIntervalMs` [setting](/docs/configuration/config-file#settings) has exceeded, the connection will be synced. This consists of:
+1. Fetching the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host.
+2. Re-indexing the repository.
This is processed in a [job queue](/docs/overview#architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large.
@@ -79,7 +67,7 @@ To learn more about how to create a connection for a specific code host, check o
-Missing your code host? [Submit a feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas).
+Missing your code host? [Submit a feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md).
## Schema reference
diff --git a/docs/docs/connections/request-new.mdx b/docs/docs/connections/request-new.mdx
index 5308736e..774e6be7 100644
--- a/docs/docs/connections/request-new.mdx
+++ b/docs/docs/connections/request-new.mdx
@@ -1,8 +1,8 @@
---
sidebarTitle: Request another host
-url: https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas
+url: https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md
title: Request another code host
icon: plus
---
-Is your code host not supported? Please open a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas).
\ No newline at end of file
+Is your code host not supported? Please open a [feature request](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md).
\ No newline at end of file
diff --git a/docs/docs/deployment-guide.mdx b/docs/docs/deployment-guide.mdx
index 7e785486..7a9725e7 100644
--- a/docs/docs/deployment-guide.mdx
+++ b/docs/docs/deployment-guide.mdx
@@ -7,7 +7,7 @@ import SupportedPlatforms from '/snippets/platform-support.mdx'
The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker.
-Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).
+Hit an issue? Please let us know on [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) or by [emailing us](mailto:team@sourcebot.dev).
@@ -32,7 +32,7 @@ The following guide will walk you through the steps to deploy Sourcebot on your
}' > config.json
```
- This config creates a single GitHub connection named `starter-connection` that specifies [Sourcebot](https://github.com/sourcebot-dev/sourcebot) as a repo to sync. [Learn more about the config file](/docs/connections/overview).
+ This config creates a single GitHub connection named `starter-connection` that specifies [Sourcebot](https://github.com/sourcebot-dev/sourcebot) as a repo to sync. [Learn more about the config file](/docs/configuration/config-file).
@@ -65,25 +65,24 @@ The following guide will walk you through the steps to deploy Sourcebot on your
Navigate to `http://localhost:3000` and complete the onboarding flow.
-
-
- By default, only email / password authentication is enabled. [Learn more about authentication](/docs/configuration/auth/overview).
-
- You're all set! You can now start searching - checkout the [syntax guide](/docs/features/search/syntax-reference) to learn more about how to search.
+ You're all set! If you'd like to setup [Ask Sourcebot](/docs/features/ask/overview), configure a language model [provider](/docs/configuration/language-model-providers).
## Next steps
---
-
-
- Learn more about how to connect your code to Sourcebot.
+
+
+ Learn how to index your code using Sourcebot
-
+
+ Learn how to configure language model providers to start using [Ask Sourcebot](/docs/features/ask/overview)
+
+
Learn more about how to setup SSO, email codes, and other authentication providers.
\ No newline at end of file
diff --git a/docs/docs/features/agents/overview.mdx b/docs/docs/features/agents/overview.mdx
index 3f5a82c6..5b3bea6f 100644
--- a/docs/docs/features/agents/overview.mdx
+++ b/docs/docs/features/agents/overview.mdx
@@ -3,9 +3,9 @@ title: "Agents Overview"
sidebarTitle: "Overview"
---
-
-Agents are currently a experimental feature. Have an idea for an agent that we haven't built? Submit a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests) on our GitHub.
-
+import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx'
+
+
Agents are automations that leverage the code indexed on Sourcebot to perform a specific task. Once you've setup Sourcebot, check out the
guides below to configure additional agents.
diff --git a/docs/docs/features/agents/review-agent.mdx b/docs/docs/features/agents/review-agent.mdx
index 74582c53..743611d0 100644
--- a/docs/docs/features/agents/review-agent.mdx
+++ b/docs/docs/features/agents/review-agent.mdx
@@ -10,7 +10,7 @@ codebase that the agent may fetch to perform the review.
This agent provides codebase-aware reviews for your PRs. For each diff, this agent fetches relevant context from Sourcebot and feeds it into an LLM for a detailed review of your changes.
-The AI Code Review Agent is [open source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started using this agent, [deploy Sourcebot](/docs/deployment-guide)
+The AI Code Review Agent is [fair source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started using this agent, [deploy Sourcebot](/docs/deployment-guide)
and then follow the configuration instructions below.

diff --git a/docs/docs/features/ask/add-model-providers.mdx b/docs/docs/features/ask/add-model-providers.mdx
new file mode 100644
index 00000000..19f03add
--- /dev/null
+++ b/docs/docs/features/ask/add-model-providers.mdx
@@ -0,0 +1,5 @@
+---
+sidebarTitle: Configure language models
+url: /docs/configuration/language-model-providers
+title: Configure Language Models
+---
\ No newline at end of file
diff --git a/docs/docs/features/ask/overview.mdx b/docs/docs/features/ask/overview.mdx
new file mode 100644
index 00000000..cd48b8c9
--- /dev/null
+++ b/docs/docs/features/ask/overview.mdx
@@ -0,0 +1,56 @@
+---
+title: Overview
+---
+
+Ask Sourcebot gives you the ability to ask complex questions about your codebase in natural language.
+
+It uses Sourcebot’s existing [code search](/docs/features/search/overview) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code,
+follow code nav references, and provide an answer that’s rich with inline citations and navigable code snippets.
+
+
+
+ Learn how to connect your language model to Sourcebot
+
+
+ Learn how to index your repos so you can ask questions about them
+
+
+ Learn how to self-host Sourcebot in a few simple steps.
+
+
+ Try Ask Sourcebot on our public demo instance.
+
+
+
+
+
+# Why do we need another AI dev tool?
+
+Existing AI dev tools (Cursor, Claude Code, Copilot) are great at generating code. However, we believe one of the hardest parts of being
+a software engineer is **understanding code**.
+
+In this domain, these tools fall short:
+- You can only ask questions about the code you have checked out locally
+- You get a wall of text that's difficult to parse, requiring you to go back and forth through different code snippets in the response
+- The richness of the explanation is limited by the fact that you're in your IDE
+
+We built Ask Sourcebot to address these problems. With Ask Sourcebot, you can:
+- Ask questions about your teams entire codebase (even on repos you don't have locally)
+- Easily parse the response with side-by-side citations and code navigation
+- Share answers with your team to spread the knowledge
+
+Being a web app is less convenient than being in your IDE, but it allows Sourcebot to provide responses in a richer UI that isn't constrained by the IDE.
+
+We believe this experience of understanding your codebase is superior, and we hope you find it useful. We'd love to know what you think! Feel free to join the discussion on our
+[GitHub](https://github.com/sourcebot-dev/sourcebot/discussions).
+
+# Troubleshooting
+
+- **Network timeouts**: If you are hitting generic "network error" message while the answer is streaming when Sourcebot is deployed in a production environment, it may be due to your load balancer or proxy not being configured to handle long-lived connections. The timeout should be configured to a sufficiently large value (e.g., 5 minutes).
\ No newline at end of file
diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx
new file mode 100644
index 00000000..527b81f0
--- /dev/null
+++ b/docs/docs/features/permission-syncing.mdx
@@ -0,0 +1,72 @@
+---
+title: "Permission syncing"
+sidebarTitle: "Permission syncing"
+tag: "experimental"
+---
+
+import LicenseKeyRequired from '/snippets/license-key-required.mdx'
+import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx'
+
+
+
+
+# Overview
+
+Permission syncing allows you to sync Access Permission Lists (ACLs) from a code host to Sourcebot. When configured, users signed into Sourcebot (via the code host's OAuth provider) will only be able to access repositories that they have access to on the code host. Practically, this means:
+
+- Code Search results will only include repositories that the user has access to.
+- Code navigation results will only include repositories that the user has access to.
+- Ask Sourcebot (and the underlying LLM) will only have access to repositories that the user has access to.
+- File browsing is scoped to the repositories that the user has access to.
+
+Permission syncing can be enabled by setting the `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` environment variable to `true`.
+
+```bash
+docker run \
+ -e EXPERIMENT_EE_PERMISSION_SYNC_ENABLED=true \
+ /* additional args */ \
+ ghcr.io/sourcebot-dev/sourcebot:latest
+```
+
+## Platform support
+
+We are actively working on supporting more code hosts. If you'd like to see a specific code host supported, please [reach out](https://www.sourcebot.dev/contact).
+
+| Platform | Permission syncing |
+|:----------|------------------------------|
+| [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ |
+| GitLab | 🛑 |
+| Bitbucket Cloud | 🛑 |
+| Bitbucket Data Center | 🛑 |
+| Gitea | 🛑 |
+| Gerrit | 🛑 |
+| Generic git host | 🛑 |
+
+# Getting started
+
+## GitHub
+
+Prerequisite: [Add GitHub as an OAuth provider](/docs/configuration/auth/providers#github).
+
+Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot:
+- Outside collaborators
+- Organization members that are direct collaborators
+- Organization members with access through team memberships
+- Organization members with access through default organization permissions
+- Organization owners.
+
+**Notes:**
+- A GitHub OAuth provider must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
+- OAuth tokens must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**.
+
+# How it works
+
+Permission syncing works by periodically syncing ACLs from the code host(s) to Sourcebot to build an internal mapping between Users and Repositories. This mapping is hydrated in two directions:
+- **User driven** : fetches the list of all repositories that a given user has access to.
+- **Repo driven** : fetches the list of all users that have access to a given repository.
+
+User driven and repo driven syncing occurs every 24 hours by default. These intervals can be configured using the following settings in the [config file](/docs/configuration/config-file):
+| Setting | Type | Default | Minimum |
+|-------------------------------------------------|---------|------------|---------|
+| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
+| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
\ No newline at end of file
diff --git a/docs/docs/features/search/multi-branch-indexing.mdx b/docs/docs/features/search/multi-branch-indexing.mdx
index dd64e279..aad65591 100644
--- a/docs/docs/features/search/multi-branch-indexing.mdx
+++ b/docs/docs/features/search/multi-branch-indexing.mdx
@@ -12,7 +12,7 @@ By default, only the default branch of a repository is indexed and can be search
## Configuration
-Multi-branch indexing is currently limited to 64 branches and tags. If this limitation impacts your use-case, please [open a discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support).
+Multi-branch indexing is currently limited to 64 branches and tags. Please see [this issue](https://github.com/sourcebot-dev/sourcebot/issues/461) for more details.
Multi-branch indexing is configured in the [connection](/docs/connections/overview) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example:
@@ -89,6 +89,6 @@ Additional info:
| Bitbucket Cloud | ✅ |
| Bitbucket Data Center | ✅ |
| Gitea | ✅ |
-| Gerrit | ❌ |
+| Gerrit | ✅ |
| Generic git host | ✅ |
diff --git a/docs/docs/features/search/overview.mdx b/docs/docs/features/search/overview.mdx
new file mode 100644
index 00000000..5f703a3c
--- /dev/null
+++ b/docs/docs/features/search/overview.mdx
@@ -0,0 +1,40 @@
+---
+title: Overview
+---
+
+Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more.
+
+
+- **Regex support:** Use regular expressions to find code with precision.
+- **Query language:** Scope searches to specific files, repos, languages, symbol definitions and more using a rich [query language](/docs/features/search/syntax-reference).
+- **Branch search:** Specify a list of branches to search across ([docs](/docs/features/search/multi-branch-indexing)).
+- **Fast & scalable:** Sourcebot uses [trigram indexing](https://en.wikipedia.org/wiki/Trigram_search), allowing it to scale to massive codebases.
+- **Syntax highlighting:** Syntax highlighting support for over [100+ languages](https://github.com/sourcebot-dev/sourcebot/blob/57724689303f351c279d37f45b6406f1d5d5d5ab/packages/web/src/lib/codemirrorLanguage.ts#L125).
+- **Multi-repository:** Search across all of your repositories in a single search.
+- **Search suggestions:** Get search suggestions as you craft your query.
+- **Filter panel:** Filter results by repository or by language.
+
+
+
+
+ Learn how to index your repos so you can ask questions about them
+
+
+ Learn how to index and search through your branches
+
+
+ Learn how to self-host Sourcebot in a few simple steps.
+
+
+ Try Sourcebot's code search on our public demo instance.
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx
index 64b04f4e..272eab62 100644
--- a/docs/docs/license-key.mdx
+++ b/docs/docs/license-key.mdx
@@ -7,7 +7,7 @@ sidebarTitle: License key
If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours
-All core Sourcebot features are available in Sourcebot OSS (MIT Licensed) without any limits. Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details.
+All core Sourcebot features are available [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) without any limits. Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details.
## Activating a license key
diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx
index 35bf8be3..96d9bc85 100644
--- a/docs/docs/overview.mdx
+++ b/docs/docs/overview.mdx
@@ -2,7 +2,10 @@
title: "Overview"
---
-[Sourcebot]((https://github.com/sourcebot-dev/sourcebot)) is an open-source, self-hosted code search tool. It allows you to search and navigate across millions of lines of code across several code host platforms.
+[Sourcebot](https://github.com/sourcebot-dev/sourcebot) is a self-hosted tool that helps you understand your codebase.
+
+- [Code search](/docs/features/search/overview): Search and navigate across all your repos and branches, no matter where they’re hosted
+- [Ask Sourcebot](/docs/features/ask): Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations
@@ -19,7 +22,7 @@ title: "Overview"
- **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine.
- **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc.
- **Scalable:** Scales to millions of lines of code.
- - **Open-source:** Core features are MIT licensed.
+ - **Fair-source:** Core features are [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license).
@@ -30,9 +33,28 @@ title: "Overview"
Find an overview of all Sourcebot features below. For details, see the individual documentation pages.
-### Fast indexed based search
+### Ask Sourcebot
-Search across millions of lines of code instantly using Sourcebot's blazingly fast indexed search. Find exactly what you are looking for with regular expressions, search filters, boolean logic, and more.
+[Ask Sourcebot](/docs/features/ask) gives you the ability to ask complex questions about your codebase, and have Sourcebot provide detailed answers with inline citations.
+
+
+- **Bring your own model:** [Configure](/docs/configuration/language-model-providers) to any language model you'd like
+- **Inline citations:** Every answer Sourcebot provides is grounded with inline citations directly into your codebase
+- **Mutli-repo:** Ask questions about any repository you have indexed on Sourcebot
+
+
+
+
+### Code Search
+
+Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more.
- **Regex support:** Use regular expressions to find code with precision.
@@ -71,7 +93,7 @@ Search across millions of lines of code instantly using Sourcebot's blazingly fa
loop
playsInline
className="w-full aspect-video"
- src="https://framerusercontent.com/assets/B9ZxrlsUeO9NJyzkKyvVV2KSU4.mp4"
+ src="/images/code_nav.mp4"
>
@@ -174,7 +196,7 @@ Sourcebot does not support horizontal scaling at this time, but it is on our roa
## License key
---
-Sourcebot's core features are available under an [MIT license](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/LICENSE) without any limits. Some [additional features](/docs/license-key#feature-availability) such as SSO and code navigation require a [license key](/docs/license-key).
+Sourcebot's core features are available under an [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) without any limits. Some [additional features](/docs/license-key#feature-availability) such as SSO and code navigation require a [license key](/docs/license-key).
diff --git a/docs/docs/upgrade/v2-to-v3-guide.mdx b/docs/docs/upgrade/v2-to-v3-guide.mdx
index 898c7af2..04e64a1f 100644
--- a/docs/docs/upgrade/v2-to-v3-guide.mdx
+++ b/docs/docs/upgrade/v2-to-v3-guide.mdx
@@ -78,7 +78,7 @@ If your deployment is dependent on these features, please [reach out](https://gi
After updating your configuration file, restart your Sourcebot deployment to pick up the new changes.
- Congrats, you've successfully migrated to v3! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support)
+ Congrats, you've successfully migrated to v3! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or on [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose).
@@ -90,4 +90,4 @@ Some things to check:
- Make sure you have a name for each `connection`, and that the name only contains letters, digits, hyphens, or underscores
- Make sure each `connection` has a `type` field with a valid value (`gitlab`, `github`, `gitea`, `gerrit`)
-Having troubles migrating from v2 to v3? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and we'll try our best to help
\ No newline at end of file
+Having troubles migrating from v2 to v3? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) and we'll try our best to help
\ No newline at end of file
diff --git a/docs/docs/upgrade/v3-to-v4-guide.mdx b/docs/docs/upgrade/v3-to-v4-guide.mdx
index 93477157..b40f0c23 100644
--- a/docs/docs/upgrade/v3-to-v4-guide.mdx
+++ b/docs/docs/upgrade/v3-to-v4-guide.mdx
@@ -40,7 +40,7 @@ Please note that the following features are no longer supported in v4:
- Congrats, you've successfully migrated to v4! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support)
+ Congrats, you've successfully migrated to v4! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose)
@@ -58,4 +58,4 @@ to finish upgrading to v4 in single-tenant mode.
- If you're hitting issues with signing into your Sourcebot instance, make sure you're setting `AUTH_URL` correctly to your deployment domain (ex. `https://sourcebot.yourcompany.com`)
-Having troubles migrating from v3 to v4? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and we'll try our best to help
\ No newline at end of file
+Having troubles migrating from v3 to v4? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) and we'll try our best to help
\ No newline at end of file
diff --git a/docs/images/ado.svg b/docs/images/ado.svg
new file mode 100644
index 00000000..e5668e9b
--- /dev/null
+++ b/docs/images/ado.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docs/images/ask_sourcebot_low_res.mp4 b/docs/images/ask_sourcebot_low_res.mp4
new file mode 100644
index 00000000..25495568
Binary files /dev/null and b/docs/images/ask_sourcebot_low_res.mp4 differ
diff --git a/docs/images/code_nav.mp4 b/docs/images/code_nav.mp4
new file mode 100644
index 00000000..754b79ff
Binary files /dev/null and b/docs/images/code_nav.mp4 differ
diff --git a/docs/snippets/bitbucket-app-password.mdx b/docs/snippets/bitbucket-app-password.mdx
index 1f52e79c..0a5fdac3 100644
--- a/docs/snippets/bitbucket-app-password.mdx
+++ b/docs/snippets/bitbucket-app-password.mdx
@@ -1,6 +1,5 @@
- Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI.
1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config:
```json
diff --git a/docs/snippets/bitbucket-token.mdx b/docs/snippets/bitbucket-token.mdx
index 8b7e1db6..94197e01 100644
--- a/docs/snippets/bitbucket-token.mdx
+++ b/docs/snippets/bitbucket-token.mdx
@@ -1,6 +1,5 @@
- Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI.
1. Add the `token` property to your connection config:
```json
diff --git a/docs/snippets/experimental-feature-warning.mdx b/docs/snippets/experimental-feature-warning.mdx
new file mode 100644
index 00000000..cdae892c
--- /dev/null
+++ b/docs/snippets/experimental-feature-warning.mdx
@@ -0,0 +1,4 @@
+
+
+This is an experimental feature. Certain functionality may be incomplete and breaking changes may ship in non-major releases. Have feedback? Submit a [issue](https://github.com/sourcebot-dev/sourcebot/issues) on GitHub.
+
diff --git a/docs/snippets/platform-support.mdx b/docs/snippets/platform-support.mdx
index 38266b2d..1687d5db 100644
--- a/docs/snippets/platform-support.mdx
+++ b/docs/snippets/platform-support.mdx
@@ -3,6 +3,43 @@
+ {/* Mintlify has a bug where linking to a file for the logo renders it with a white background, so we have to embed it directly */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
diff --git a/docs/snippets/schemas/v3/azuredevops.schema.mdx b/docs/snippets/schemas/v3/azuredevops.schema.mdx
new file mode 100644
index 00000000..fab796de
--- /dev/null
+++ b/docs/snippets/schemas/v3/azuredevops.schema.mdx
@@ -0,0 +1,206 @@
+{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */}
+```json
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+}
+```
diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx
index 9731bdeb..48750ae1 100644
--- a/docs/snippets/schemas/v3/connection.schema.mdx
+++ b/docs/snippets/schemas/v3/connection.schema.mdx
@@ -343,6 +343,11 @@
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
@@ -638,6 +643,47 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
@@ -823,6 +869,209 @@
},
"additionalProperties": false
},
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+ },
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
diff --git a/docs/snippets/schemas/v3/gerrit.schema.mdx b/docs/snippets/schemas/v3/gerrit.schema.mdx
index 561bda80..cd8c0a64 100644
--- a/docs/snippets/schemas/v3/gerrit.schema.mdx
+++ b/docs/snippets/schemas/v3/gerrit.schema.mdx
@@ -59,6 +59,47 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
diff --git a/docs/snippets/schemas/v3/gitlab.schema.mdx b/docs/snippets/schemas/v3/gitlab.schema.mdx
index feadeaac..1d322f44 100644
--- a/docs/snippets/schemas/v3/gitlab.schema.mdx
+++ b/docs/snippets/schemas/v3/gitlab.schema.mdx
@@ -126,6 +126,11 @@
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx
index 0b7907ed..9df06a9f 100644
--- a/docs/snippets/schemas/v3/index.schema.mdx
+++ b/docs/snippets/schemas/v3/index.schema.mdx
@@ -69,6 +69,16 @@
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
+ },
+ "experiment_repoDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
+ },
+ "experiment_userDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
}
},
"additionalProperties": false
@@ -92,6 +102,13 @@
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -105,14 +122,18 @@
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
}
},
@@ -184,6 +205,16 @@
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
+ },
+ "experiment_repoDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
+ },
+ "experiment_userDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
}
},
"additionalProperties": false
@@ -211,6 +242,13 @@
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -224,14 +262,18 @@
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
}
},
@@ -584,6 +626,11 @@
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
@@ -879,6 +926,47 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
@@ -1064,6 +1152,209 @@
},
"additionalProperties": false
},
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+ },
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
@@ -1136,6 +1427,2852 @@
}
},
"additionalProperties": false
+ },
+ "models": {
+ "type": "array",
+ "description": "Defines a collection of language models that are available to Sourcebot.",
+ "items": {
+ "type": "object",
+ "title": "LanguageModel",
+ "definitions": {
+ "AmazonBedrockLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AzureLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "DeepSeekLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleGenerativeAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexAnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "MistralLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAICompatibleLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ "OpenRouterLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "XaiLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
}
},
"additionalProperties": false
diff --git a/docs/snippets/schemas/v3/languageModel.schema.mdx b/docs/snippets/schemas/v3/languageModel.schema.mdx
new file mode 100644
index 00000000..d90c4a76
--- /dev/null
+++ b/docs/snippets/schemas/v3/languageModel.schema.mdx
@@ -0,0 +1,2845 @@
+{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */}
+```json
+{
+ "type": "object",
+ "title": "LanguageModel",
+ "definitions": {
+ "AmazonBedrockLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AzureLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "DeepSeekLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleGenerativeAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexAnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "MistralLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAICompatibleLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ "OpenRouterLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "XaiLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ ]
+}
+```
diff --git a/docs/snippets/schemas/v3/searchContext.schema.mdx b/docs/snippets/schemas/v3/searchContext.schema.mdx
index 01477bef..2f0849cf 100644
--- a/docs/snippets/schemas/v3/searchContext.schema.mdx
+++ b/docs/snippets/schemas/v3/searchContext.schema.mdx
@@ -19,6 +19,13 @@
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -32,14 +39,18 @@
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
}
```
diff --git a/docs/snippets/schemas/v3/shared.schema.mdx b/docs/snippets/schemas/v3/shared.schema.mdx
index 97fdbabf..82ca9195 100644
--- a/docs/snippets/schemas/v3/shared.schema.mdx
+++ b/docs/snippets/schemas/v3/shared.schema.mdx
@@ -74,6 +74,94 @@
}
},
"additionalProperties": false
+ },
+ "LanguageModelHeaders": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "LanguageModelQueryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
}
}
}
diff --git a/entrypoint.sh b/entrypoint.sh
index 6753353f..b031b326 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,6 +1,24 @@
#!/bin/sh
set -e
+# Check if DATABASE_URL is not set
+if [ -z "$DATABASE_URL" ]; then
+ # Check if the individual database variables are set and construct the URL
+ if [ -n "$DATABASE_HOST" ] && [ -n "$DATABASE_USERNAME" ] && [ -n "$DATABASE_PASSWORD" ] && [ -n "$DATABASE_NAME" ]; then
+ DATABASE_URL="postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}/${DATABASE_NAME}"
+
+ if [ -n "$DATABASE_ARGS" ]; then
+ DATABASE_URL="${DATABASE_URL}?$DATABASE_ARGS"
+ fi
+
+ export DATABASE_URL
+ else
+ # Otherwise, fallback to a default value
+ DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
+ export DATABASE_URL
+ fi
+fi
+
if [ "$DATABASE_URL" = "postgresql://postgres@localhost:5432/sourcebot" ]; then
DATABASE_EMBEDDED="true"
fi
diff --git a/package.json b/package.json
index e118780e..c5909e76 100644
--- a/package.json
+++ b/package.json
@@ -14,8 +14,10 @@
"watch:mcp": "yarn workspace @sourcebot/mcp build:watch",
"watch:schemas": "yarn workspace @sourcebot/schemas watch",
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
+ "dev:prisma:generate": "yarn with-env yarn workspace @sourcebot/db prisma:generate",
"dev:prisma:studio": "yarn with-env yarn workspace @sourcebot/db prisma:studio",
"dev:prisma:migrate:reset": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:reset",
+ "dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push",
"build:deps": "yarn workspaces foreach -R --from '{@sourcebot/schemas,@sourcebot/error,@sourcebot/crypto,@sourcebot/db,@sourcebot/shared}' run build"
},
"devDependencies": {
@@ -23,5 +25,8 @@
"dotenv-cli": "^8.0.0",
"npm-run-all": "^4.1.5"
},
- "packageManager": "yarn@4.7.0"
+ "packageManager": "yarn@4.7.0",
+ "resolutions": {
+ "prettier": "3.5.3"
+ }
}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 6338b0bb..dade7893 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -15,7 +15,7 @@
"@types/micromatch": "^4.0.9",
"@types/node": "^22.7.5",
"cross-env": "^7.0.3",
- "json-schema-to-typescript": "^15.0.2",
+ "json-schema-to-typescript": "^15.0.4",
"tsc-watch": "^6.2.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2",
@@ -37,6 +37,7 @@
"@t3-oss/env-core": "^0.12.0",
"@types/express": "^5.0.0",
"argparse": "^2.0.1",
+ "azure-devops-node-api": "^15.1.1",
"bullmq": "^5.34.10",
"cross-fetch": "^4.0.0",
"dotenv": "^16.4.5",
diff --git a/packages/backend/src/azuredevops.ts b/packages/backend/src/azuredevops.ts
new file mode 100644
index 00000000..a06b9c09
--- /dev/null
+++ b/packages/backend/src/azuredevops.ts
@@ -0,0 +1,348 @@
+import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type";
+import { createLogger } from "@sourcebot/logger";
+import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
+import micromatch from "micromatch";
+import { PrismaClient } from "@sourcebot/db";
+import { BackendException, BackendError } from "@sourcebot/error";
+import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
+import * as Sentry from "@sentry/node";
+import * as azdev from "azure-devops-node-api";
+import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js";
+
+const logger = createLogger('azuredevops');
+const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com";
+
+
+function buildOrgUrl(baseUrl: string, org: string, useTfsPath: boolean): string {
+ const tfsSegment = useTfsPath ? '/tfs' : '';
+ return `${baseUrl}${tfsSegment}/${org}`;
+}
+
+function createAzureDevOpsConnection(
+ orgUrl: string,
+ token: string,
+): azdev.WebApi {
+ const authHandler = azdev.getPersonalAccessTokenHandler(token);
+ return new azdev.WebApi(orgUrl, authHandler);
+}
+
+export const getAzureDevOpsReposFromConfig = async (
+ config: AzureDevOpsConnectionConfig,
+ orgId: number,
+ db: PrismaClient
+) => {
+ const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`;
+
+ const token = config.token ?
+ await getTokenFromConfig(config.token, orgId, db, logger) :
+ undefined;
+
+ if (!token) {
+ const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
+ message: 'Azure DevOps requires a Personal Access Token',
+ });
+ Sentry.captureException(e);
+ throw e;
+ }
+
+ const useTfsPath = config.useTfsPath || false;
+ let allRepos: GitRepository[] = [];
+ let notFound: {
+ users: string[],
+ orgs: string[],
+ repos: string[],
+ } = {
+ users: [],
+ orgs: [],
+ repos: [],
+ };
+
+ if (config.orgs) {
+ const { validRepos, notFoundOrgs } = await getReposForOrganizations(
+ config.orgs,
+ baseUrl,
+ token,
+ useTfsPath
+ );
+ allRepos = allRepos.concat(validRepos);
+ notFound.orgs = notFoundOrgs;
+ }
+
+ if (config.projects) {
+ const { validRepos, notFoundProjects } = await getReposForProjects(
+ config.projects,
+ baseUrl,
+ token,
+ useTfsPath
+ );
+ allRepos = allRepos.concat(validRepos);
+ notFound.repos = notFound.repos.concat(notFoundProjects);
+ }
+
+ if (config.repos) {
+ const { validRepos, notFoundRepos } = await getRepos(
+ config.repos,
+ baseUrl,
+ token,
+ useTfsPath
+ );
+ allRepos = allRepos.concat(validRepos);
+ notFound.repos = notFound.repos.concat(notFoundRepos);
+ }
+
+ let repos = allRepos
+ .filter((repo) => {
+ const isExcluded = shouldExcludeRepo({
+ repo,
+ exclude: config.exclude,
+ });
+
+ return !isExcluded;
+ });
+
+ logger.debug(`Found ${repos.length} total repositories.`);
+
+ return {
+ validRepos: repos,
+ notFound,
+ };
+};
+
+export const shouldExcludeRepo = ({
+ repo,
+ exclude
+}: {
+ repo: GitRepository,
+ exclude?: AzureDevOpsConnectionConfig['exclude']
+}) => {
+ let reason = '';
+ const repoName = `${repo.project!.name}/${repo.name}`;
+
+ const shouldExclude = (() => {
+ if (!repo.remoteUrl) {
+ reason = 'remoteUrl is undefined';
+ return true;
+ }
+
+ if (!!exclude?.disabled && repo.isDisabled) {
+ reason = `\`exclude.disabled\` is true`;
+ return true;
+ }
+
+ if (exclude?.repos) {
+ if (micromatch.isMatch(repoName, exclude.repos)) {
+ reason = `\`exclude.repos\` contains ${repoName}`;
+ return true;
+ }
+ }
+
+ if (exclude?.projects) {
+ if (micromatch.isMatch(repo.project!.name!, exclude.projects)) {
+ reason = `\`exclude.projects\` contains ${repo.project!.name}`;
+ return true;
+ }
+ }
+
+ const repoSizeInBytes = repo.size || 0;
+ if (exclude?.size && repoSizeInBytes) {
+ const min = exclude.size.min;
+ const max = exclude.size.max;
+
+ if (min && repoSizeInBytes < min) {
+ reason = `repo is less than \`exclude.size.min\`=${min} bytes.`;
+ return true;
+ }
+
+ if (max && repoSizeInBytes > max) {
+ reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`;
+ return true;
+ }
+ }
+
+ return false;
+ })();
+
+ if (shouldExclude) {
+ logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`);
+ return true;
+ }
+
+ return false;
+};
+
+async function getReposForOrganizations(
+ organizations: string[],
+ baseUrl: string,
+ token: string,
+ useTfsPath: boolean
+) {
+ const results = await Promise.allSettled(organizations.map(async (org) => {
+ try {
+ logger.debug(`Fetching repositories for organization ${org}...`);
+
+ const { durationMs, data } = await measure(async () => {
+ const fetchFn = async () => {
+ const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath);
+ const connection = createAzureDevOpsConnection(orgUrl, token); // useTfsPath already handled in orgUrl
+
+ const coreApi = await connection.getCoreApi();
+ const gitApi = await connection.getGitApi();
+
+ const projects = await coreApi.getProjects();
+ const allRepos: GitRepository[] = [];
+ for (const project of projects) {
+ if (!project.id) {
+ logger.warn(`Encountered project in org ${org} with no id: ${project.name}`);
+ continue;
+ }
+
+ try {
+ const repos = await gitApi.getRepositories(project.id);
+ allRepos.push(...repos);
+ } catch (error) {
+ logger.warn(`Failed to fetch repositories for project ${project.name}: ${error}`);
+ }
+ }
+
+ return allRepos;
+ };
+
+ return fetchWithRetry(fetchFn, `organization ${org}`, logger);
+ });
+
+ logger.debug(`Found ${data.length} repositories in organization ${org} in ${durationMs}ms.`);
+ return {
+ type: 'valid' as const,
+ data
+ };
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error(`Failed to fetch repositories for organization ${org}.`, error);
+
+ // Check if it's a 404-like error (organization not found)
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ logger.error(`Organization ${org} not found or no access`);
+ return {
+ type: 'notFound' as const,
+ value: org
+ };
+ }
+ throw error;
+ }
+ }));
+
+ throwIfAnyFailed(results);
+ const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results);
+
+ return {
+ validRepos,
+ notFoundOrgs,
+ };
+}
+
+async function getReposForProjects(
+ projects: string[],
+ baseUrl: string,
+ token: string,
+ useTfsPath: boolean
+) {
+ const results = await Promise.allSettled(projects.map(async (project) => {
+ try {
+ const [org, projectName] = project.split('/');
+ logger.debug(`Fetching repositories for project ${project}...`);
+
+ const { durationMs, data } = await measure(async () => {
+ const fetchFn = async () => {
+ const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath);
+ const connection = createAzureDevOpsConnection(orgUrl, token);
+ const gitApi = await connection.getGitApi();
+
+ const repos = await gitApi.getRepositories(projectName);
+ return repos;
+ };
+
+ return fetchWithRetry(fetchFn, `project ${project}`, logger);
+ });
+
+ logger.debug(`Found ${data.length} repositories in project ${project} in ${durationMs}ms.`);
+ return {
+ type: 'valid' as const,
+ data
+ };
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error(`Failed to fetch repositories for project ${project}.`, error);
+
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ logger.error(`Project ${project} not found or no access`);
+ return {
+ type: 'notFound' as const,
+ value: project
+ };
+ }
+ throw error;
+ }
+ }));
+
+ throwIfAnyFailed(results);
+ const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
+
+ return {
+ validRepos,
+ notFoundProjects,
+ };
+}
+
+async function getRepos(
+ repoList: string[],
+ baseUrl: string,
+ token: string,
+ useTfsPath: boolean
+) {
+ const results = await Promise.allSettled(repoList.map(async (repo) => {
+ try {
+ const [org, projectName, repoName] = repo.split('/');
+ logger.info(`Fetching repository info for ${repo}...`);
+
+ const { durationMs, data: result } = await measure(async () => {
+ const fetchFn = async () => {
+ const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath);
+ const connection = createAzureDevOpsConnection(orgUrl, token);
+ const gitApi = await connection.getGitApi();
+
+ const repo = await gitApi.getRepository(repoName, projectName);
+ return repo;
+ };
+
+ return fetchWithRetry(fetchFn, repo, logger);
+ });
+
+ logger.info(`Found info for repository ${repo} in ${durationMs}ms`);
+ return {
+ type: 'valid' as const,
+ data: [result]
+ };
+
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error(`Failed to fetch repository ${repo}.`, error);
+
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ logger.error(`Repository ${repo} not found or no access`);
+ return {
+ type: 'notFound' as const,
+ value: repo
+ };
+ }
+ throw error;
+ }
+ }));
+
+ throwIfAnyFailed(results);
+ const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
+
+ return {
+ validRepos,
+ notFoundRepos,
+ };
+}
\ No newline at end of file
diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts
index e204850c..cfa591cc 100644
--- a/packages/backend/src/bitbucket.ts
+++ b/packages/backend/src/bitbucket.ts
@@ -148,13 +148,14 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
**/
const getPaginatedCloud = async (
path: CloudGetRequestPath,
- get: (url: CloudGetRequestPath) => Promise>
+ get: (path: CloudGetRequestPath, query?: Record) => Promise>
): Promise => {
const results: T[] = [];
- let url = path;
+ let nextPath = path;
+ let nextQuery = undefined;
while (true) {
- const response = await get(url);
+ const response = await get(nextPath, nextQuery);
if (!response.values || response.values.length === 0) {
break;
@@ -166,25 +167,38 @@ const getPaginatedCloud = async (
break;
}
- url = response.next as CloudGetRequestPath;
+ const parsedUrl = parseUrl(response.next);
+ nextPath = parsedUrl.path as CloudGetRequestPath;
+ nextQuery = parsedUrl.query;
}
return results;
}
-
+
+/**
+ * Parse the url into a path and query parameters to be used with the api client (openapi-fetch)
+ */
+function parseUrl(url: string): { path: string; query: Record; } {
+ const fullUrl = new URL(url);
+ const path = fullUrl.pathname.replace(/^\/\d+(\.\d+)*/, ''); // remove version number in the beginning of the path
+ const query = Object.fromEntries(fullUrl.searchParams);
+ logger.debug(`Parsed url ${url} into path ${path} and query ${JSON.stringify(query)}`);
+ return { path, query };
+}
+
async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> {
const results = await Promise.allSettled(workspaces.map(async (workspace) => {
try {
logger.debug(`Fetching all repos for workspace ${workspace}...`);
- const path = `/repositories/${workspace}` as CloudGetRequestPath;
const { durationMs, data } = await measure(async () => {
- const fetchFn = () => getPaginatedCloud(path, async (url) => {
- const response = await client.apiClient.GET(url, {
+ const fetchFn = () => getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => {
+ const response = await client.apiClient.GET(path, {
params: {
path: {
workspace,
- }
+ },
+ query: query,
}
});
const { data, error } = response;
@@ -238,11 +252,14 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`);
try {
- const path = `/repositories/${workspace}` as CloudGetRequestPath;
- const repos = await getPaginatedCloud(path, async (url) => {
- const response = await client.apiClient.GET(url, {
+ const repos = await getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => {
+ const response = await client.apiClient.GET(path, {
params: {
+ path: {
+ workspace,
+ },
query: {
+ ...query,
q: `project.key="${project_name}"`
}
}
diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts
index f025bdf7..ebbffe73 100644
--- a/packages/backend/src/connectionManager.ts
+++ b/packages/backend/src/connectionManager.ts
@@ -4,19 +4,13 @@ import { Settings } from "./types.js";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { createLogger } from "@sourcebot/logger";
import { Redis } from 'ioredis';
-import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js";
+import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileAzureDevOpsConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js";
import { BackendError, BackendException } from "@sourcebot/error";
import { captureEvent } from "./posthog.js";
import { env } from "./env.js";
import * as Sentry from "@sentry/node";
import { loadConfig, syncSearchContexts } from "@sourcebot/shared";
-interface IConnectionManager {
- scheduleConnectionSync: (connection: Connection) => Promise;
- registerPollingCallback: () => void;
- dispose: () => void;
-}
-
const QUEUE_NAME = 'connectionSyncQueue';
type JobPayload = {
@@ -30,10 +24,11 @@ type JobResult = {
repoCount: number,
}
-export class ConnectionManager implements IConnectionManager {
+export class ConnectionManager {
private worker: Worker;
private queue: Queue;
private logger = createLogger('connection-manager');
+ private interval?: NodeJS.Timeout;
constructor(
private db: PrismaClient,
@@ -75,8 +70,9 @@ export class ConnectionManager implements IConnectionManager {
});
}
- public async registerPollingCallback() {
- setInterval(async () => {
+ public startScheduler() {
+ this.logger.debug('Starting scheduler');
+ this.interval = setInterval(async () => {
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
const connections = await this.db.connection.findMany({
where: {
@@ -177,6 +173,9 @@ export class ConnectionManager implements IConnectionManager {
case 'bitbucket': {
return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db);
}
+ case 'azuredevops': {
+ return await compileAzureDevOpsConfig(config, job.data.connectionId, orgId, this.db, abortController);
+ }
case 'git': {
return await compileGenericGitHostConfig(config, job.data.connectionId, orgId);
}
@@ -366,6 +365,9 @@ export class ConnectionManager implements IConnectionManager {
}
public dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
this.worker.close();
this.queue.close();
}
diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts
index c0d77f05..89778fb2 100644
--- a/packages/backend/src/constants.ts
+++ b/packages/backend/src/constants.ts
@@ -15,5 +15,11 @@ export const DEFAULT_SETTINGS: Settings = {
maxRepoGarbageCollectionJobConcurrency: 8,
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
- enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
+ enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
+ experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
+ experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
}
+
+export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
+ 'github',
+];
\ No newline at end of file
diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts
new file mode 100644
index 00000000..f411c3e3
--- /dev/null
+++ b/packages/backend/src/ee/repoPermissionSyncer.ts
@@ -0,0 +1,274 @@
+import * as Sentry from "@sentry/node";
+import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { hasEntitlement } from "@sourcebot/shared";
+import { Job, Queue, Worker } from 'bullmq';
+import { Redis } from 'ioredis';
+import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
+import { env } from "../env.js";
+import { createOctokitFromToken, getRepoCollaborators } from "../github.js";
+import { Settings } from "../types.js";
+import { getAuthCredentialsForRepo } from "../utils.js";
+
+type RepoPermissionSyncJob = {
+ jobId: string;
+}
+
+const QUEUE_NAME = 'repoPermissionSyncQueue';
+
+const logger = createLogger('repo-permission-syncer');
+
+
+export class RepoPermissionSyncer {
+ private queue: Queue;
+ private worker: Worker;
+ private interval?: NodeJS.Timeout;
+
+ constructor(
+ private db: PrismaClient,
+ private settings: Settings,
+ redis: Redis,
+ ) {
+ this.queue = new Queue(QUEUE_NAME, {
+ connection: redis,
+ });
+ this.worker = new Worker(QUEUE_NAME, this.runJob.bind(this), {
+ connection: redis,
+ concurrency: 1,
+ });
+ this.worker.on('completed', this.onJobCompleted.bind(this));
+ this.worker.on('failed', this.onJobFailed.bind(this));
+ }
+
+ public startScheduler() {
+ if (!hasEntitlement('permission-syncing')) {
+ throw new Error('Permission syncing is not supported in current plan.');
+ }
+
+ logger.debug('Starting scheduler');
+
+ this.interval = setInterval(async () => {
+ // @todo: make this configurable
+ const thresholdDate = new Date(Date.now() - this.settings.experiment_repoDrivenPermissionSyncIntervalMs);
+
+ const repos = await this.db.repo.findMany({
+ // Repos need their permissions to be synced against the code host when...
+ where: {
+ // They belong to a code host that supports permissions syncing
+ AND: [
+ {
+ external_codeHostType: {
+ in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES,
+ }
+ },
+ {
+ OR: [
+ { permissionSyncedAt: null },
+ { permissionSyncedAt: { lt: thresholdDate } },
+ ],
+ },
+ {
+ NOT: {
+ permissionSyncJobs: {
+ some: {
+ OR: [
+ // Don't schedule if there are active jobs
+ {
+ status: {
+ in: [
+ RepoPermissionSyncJobStatus.PENDING,
+ RepoPermissionSyncJobStatus.IN_PROGRESS,
+ ],
+ }
+ },
+ // Don't schedule if there are recent failed jobs (within the threshold date). Note `gt` is used here since this is a inverse condition.
+ {
+ AND: [
+ { status: RepoPermissionSyncJobStatus.FAILED },
+ { completedAt: { gt: thresholdDate } },
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ ]
+ }
+ });
+
+ await this.schedulePermissionSync(repos);
+ }, 1000 * 5);
+ }
+
+ public dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
+ this.worker.close();
+ this.queue.close();
+ }
+
+ private async schedulePermissionSync(repos: Repo[]) {
+ await this.db.$transaction(async (tx) => {
+ const jobs = await tx.repoPermissionSyncJob.createManyAndReturn({
+ data: repos.map(repo => ({
+ repoId: repo.id,
+ })),
+ });
+
+ await this.queue.addBulk(jobs.map((job) => ({
+ name: 'repoPermissionSyncJob',
+ data: {
+ jobId: job.id,
+ },
+ opts: {
+ removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
+ removeOnFail: env.REDIS_REMOVE_ON_FAIL,
+ }
+ })))
+ });
+ }
+
+ private async runJob(job: Job) {
+ const id = job.data.jobId;
+ const { repo } = await this.db.repoPermissionSyncJob.update({
+ where: {
+ id,
+ },
+ data: {
+ status: RepoPermissionSyncJobStatus.IN_PROGRESS,
+ },
+ select: {
+ repo: {
+ include: {
+ connections: {
+ include: {
+ connection: true,
+ }
+ }
+ }
+ }
+ }
+ });
+
+ if (!repo) {
+ throw new Error(`Repo ${id} not found`);
+ }
+
+ logger.info(`Syncing permissions for repo ${repo.displayName}...`);
+
+ const credentials = await getAuthCredentialsForRepo(repo, this.db, logger);
+ if (!credentials) {
+ throw new Error(`No credentials found for repo ${id}`);
+ }
+
+ const userIds = await (async () => {
+ if (repo.external_codeHostType === 'github') {
+ const { octokit } = await createOctokitFromToken({
+ token: credentials.token,
+ url: credentials.hostUrl,
+ });
+
+ // @note: this is a bit of a hack since the displayName _might_ not be set..
+ // however, this property was introduced many versions ago and _should_ be set
+ // on each connection sync. Let's throw an error just in case.
+ if (!repo.displayName) {
+ throw new Error(`Repo ${id} does not have a displayName`);
+ }
+
+ const [owner, repoName] = repo.displayName.split('/');
+
+ const collaborators = await getRepoCollaborators(owner, repoName, octokit);
+ const githubUserIds = collaborators.map(collaborator => collaborator.id.toString());
+
+ const accounts = await this.db.account.findMany({
+ where: {
+ provider: 'github',
+ providerAccountId: {
+ in: githubUserIds,
+ }
+ },
+ select: {
+ userId: true,
+ },
+ });
+
+ return accounts.map(account => account.userId);
+ }
+
+ return [];
+ })();
+
+ await this.db.$transaction([
+ this.db.repo.update({
+ where: {
+ id: repo.id,
+ },
+ data: {
+ permittedUsers: {
+ deleteMany: {},
+ }
+ }
+ }),
+ this.db.userToRepoPermission.createMany({
+ data: userIds.map(userId => ({
+ userId,
+ repoId: repo.id,
+ })),
+ })
+ ]);
+ }
+
+ private async onJobCompleted(job: Job) {
+ const { repo } = await this.db.repoPermissionSyncJob.update({
+ where: {
+ id: job.data.jobId,
+ },
+ data: {
+ status: RepoPermissionSyncJobStatus.COMPLETED,
+ repo: {
+ update: {
+ permissionSyncedAt: new Date(),
+ }
+ },
+ completedAt: new Date(),
+ },
+ select: {
+ repo: true
+ }
+ });
+
+ logger.info(`Permissions synced for repo ${repo.displayName ?? repo.name}`);
+ }
+
+ private async onJobFailed(job: Job | undefined, err: Error) {
+ Sentry.captureException(err, {
+ tags: {
+ jobId: job?.data.jobId,
+ queue: QUEUE_NAME,
+ }
+ });
+
+ const errorMessage = (repoName: string) => `Repo permission sync job failed for repo ${repoName}: ${err.message}`;
+
+ if (job) {
+ const { repo } = await this.db.repoPermissionSyncJob.update({
+ where: {
+ id: job.data.jobId,
+ },
+ data: {
+ status: RepoPermissionSyncJobStatus.FAILED,
+ completedAt: new Date(),
+ errorMessage: err.message,
+ },
+ select: {
+ repo: true
+ },
+ });
+ logger.error(errorMessage(repo.displayName ?? repo.name));
+ } else {
+ logger.error(errorMessage('unknown repo (id not found)'));
+ }
+ }
+}
diff --git a/packages/backend/src/ee/userPermissionSyncer.ts b/packages/backend/src/ee/userPermissionSyncer.ts
new file mode 100644
index 00000000..90ae8629
--- /dev/null
+++ b/packages/backend/src/ee/userPermissionSyncer.ts
@@ -0,0 +1,266 @@
+import * as Sentry from "@sentry/node";
+import { PrismaClient, User, UserPermissionSyncJobStatus } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { Job, Queue, Worker } from "bullmq";
+import { Redis } from "ioredis";
+import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
+import { env } from "../env.js";
+import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js";
+import { hasEntitlement } from "@sourcebot/shared";
+import { Settings } from "../types.js";
+
+const logger = createLogger('user-permission-syncer');
+
+const QUEUE_NAME = 'userPermissionSyncQueue';
+
+type UserPermissionSyncJob = {
+ jobId: string;
+}
+
+
+export class UserPermissionSyncer {
+ private queue: Queue;
+ private worker: Worker;
+ private interval?: NodeJS.Timeout;
+
+ constructor(
+ private db: PrismaClient,
+ private settings: Settings,
+ redis: Redis,
+ ) {
+ this.queue = new Queue(QUEUE_NAME, {
+ connection: redis,
+ });
+ this.worker = new Worker(QUEUE_NAME, this.runJob.bind(this), {
+ connection: redis,
+ concurrency: 1,
+ });
+ this.worker.on('completed', this.onJobCompleted.bind(this));
+ this.worker.on('failed', this.onJobFailed.bind(this));
+ }
+
+ public startScheduler() {
+ if (!hasEntitlement('permission-syncing')) {
+ throw new Error('Permission syncing is not supported in current plan.');
+ }
+
+ logger.debug('Starting scheduler');
+
+ this.interval = setInterval(async () => {
+ const thresholdDate = new Date(Date.now() - this.settings.experiment_userDrivenPermissionSyncIntervalMs);
+
+ const users = await this.db.user.findMany({
+ where: {
+ AND: [
+ {
+ accounts: {
+ some: {
+ provider: {
+ in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES
+ }
+ }
+ }
+ },
+ {
+ OR: [
+ { permissionSyncedAt: null },
+ { permissionSyncedAt: { lt: thresholdDate } },
+ ]
+ },
+ {
+ NOT: {
+ permissionSyncJobs: {
+ some: {
+ OR: [
+ // Don't schedule if there are active jobs
+ {
+ status: {
+ in: [
+ UserPermissionSyncJobStatus.PENDING,
+ UserPermissionSyncJobStatus.IN_PROGRESS,
+ ],
+ }
+ },
+ // Don't schedule if there are recent failed jobs (within the threshold date). Note `gt` is used here since this is a inverse condition.
+ {
+ AND: [
+ { status: UserPermissionSyncJobStatus.FAILED },
+ { completedAt: { gt: thresholdDate } },
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ ]
+ }
+ });
+
+ await this.schedulePermissionSync(users);
+ }, 1000 * 5);
+ }
+
+ public dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
+ this.worker.close();
+ this.queue.close();
+ }
+
+ private async schedulePermissionSync(users: User[]) {
+ await this.db.$transaction(async (tx) => {
+ const jobs = await tx.userPermissionSyncJob.createManyAndReturn({
+ data: users.map(user => ({
+ userId: user.id,
+ })),
+ });
+
+ await this.queue.addBulk(jobs.map((job) => ({
+ name: 'userPermissionSyncJob',
+ data: {
+ jobId: job.id,
+ },
+ opts: {
+ removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
+ removeOnFail: env.REDIS_REMOVE_ON_FAIL,
+ }
+ })))
+ });
+ }
+
+ private async runJob(job: Job) {
+ const id = job.data.jobId;
+ const { user } = await this.db.userPermissionSyncJob.update({
+ where: {
+ id,
+ },
+ data: {
+ status: UserPermissionSyncJobStatus.IN_PROGRESS,
+ },
+ select: {
+ user: {
+ include: {
+ accounts: true,
+ }
+ }
+ }
+ });
+
+ if (!user) {
+ throw new Error(`User ${id} not found`);
+ }
+
+ logger.info(`Syncing permissions for user ${user.email}...`);
+
+ // Get a list of all repos that the user has access to from all connected accounts.
+ const repoIds = await (async () => {
+ const aggregatedRepoIds: Set = new Set();
+
+ for (const account of user.accounts) {
+ if (account.provider === 'github') {
+ if (!account.access_token) {
+ throw new Error(`User '${user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`);
+ }
+
+ const { octokit } = await createOctokitFromToken({
+ token: account.access_token,
+ url: env.AUTH_EE_GITHUB_BASE_URL,
+ });
+ // @note: we only care about the private repos since we don't need to build a mapping
+ // for public repos.
+ // @see: packages/web/src/prisma.ts
+ const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit);
+ const gitHubRepoIds = githubRepos.map(repo => repo.id.toString());
+
+ const repos = await this.db.repo.findMany({
+ where: {
+ external_codeHostType: 'github',
+ external_id: {
+ in: gitHubRepoIds,
+ }
+ }
+ });
+
+ repos.forEach(repo => aggregatedRepoIds.add(repo.id));
+ }
+ }
+
+ return Array.from(aggregatedRepoIds);
+ })();
+
+ await this.db.$transaction([
+ this.db.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ accessibleRepos: {
+ deleteMany: {},
+ }
+ }
+ }),
+ this.db.userToRepoPermission.createMany({
+ data: repoIds.map(repoId => ({
+ userId: user.id,
+ repoId,
+ })),
+ skipDuplicates: true,
+ })
+ ]);
+ }
+
+ private async onJobCompleted(job: Job) {
+ const { user } = await this.db.userPermissionSyncJob.update({
+ where: {
+ id: job.data.jobId,
+ },
+ data: {
+ status: UserPermissionSyncJobStatus.COMPLETED,
+ user: {
+ update: {
+ permissionSyncedAt: new Date(),
+ }
+ },
+ completedAt: new Date(),
+ },
+ select: {
+ user: true
+ }
+ });
+
+ logger.info(`Permissions synced for user ${user.email}`);
+ }
+
+ private async onJobFailed(job: Job | undefined, err: Error) {
+ Sentry.captureException(err, {
+ tags: {
+ jobId: job?.data.jobId,
+ queue: QUEUE_NAME,
+ }
+ });
+
+ const errorMessage = (email: string) => `User permission sync job failed for user ${email}: ${err.message}`;
+
+ if (job) {
+ const { user } = await this.db.userPermissionSyncJob.update({
+ where: {
+ id: job.data.jobId,
+ },
+ data: {
+ status: UserPermissionSyncJobStatus.FAILED,
+ completedAt: new Date(),
+ errorMessage: err.message,
+ },
+ select: {
+ user: true,
+ }
+ });
+
+ logger.error(errorMessage(user.email ?? user.id));
+ } else {
+ logger.error(errorMessage('unknown user (id not found)'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts
index 735361a8..80bbba5e 100644
--- a/packages/backend/src/env.ts
+++ b/packages/backend/src/env.ts
@@ -43,6 +43,7 @@ export const env = createEnv({
LOGTAIL_TOKEN: z.string().optional(),
LOGTAIL_HOST: z.string().url().optional(),
+ SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"),
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"),
CONFIG_PATH: z.string().optional(),
@@ -51,6 +52,9 @@ export const env = createEnv({
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS: numberSchema.default(60 * 10),
+
+ EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
+ AUTH_EE_GITHUB_BASE_URL: z.string().optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
diff --git a/packages/backend/src/git.ts b/packages/backend/src/git.ts
index d3dfc76f..c1110625 100644
--- a/packages/backend/src/git.ts
+++ b/packages/backend/src/git.ts
@@ -1,53 +1,102 @@
import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git';
+import { mkdir } from 'node:fs/promises';
+import { env } from './env.js';
type onProgressFn = (event: SimpleGitProgressEvent) => void;
-export const cloneRepository = async (cloneURL: string, path: string, onProgress?: onProgressFn) => {
- const git = simpleGit({
- progress: onProgress,
- });
+export const cloneRepository = async (
+ {
+ cloneUrl,
+ authHeader,
+ path,
+ onProgress,
+ }: {
+ cloneUrl: string,
+ authHeader?: string,
+ path: string,
+ onProgress?: onProgressFn
+ }
+) => {
try {
- await git.clone(
- cloneURL,
- path,
- [
- "--bare",
- ]
- );
+ await mkdir(path, { recursive: true });
- await git.cwd({
+ const git = simpleGit({
+ progress: onProgress,
+ }).cwd({
path,
- }).addConfig("remote.origin.fetch", "+refs/heads/*:refs/heads/*");
+ })
+
+ const cloneArgs = [
+ "--bare",
+ ...(authHeader ? ["-c", `http.extraHeader=${authHeader}`] : [])
+ ];
+
+ await git.clone(cloneUrl, path, cloneArgs);
+
+ await unsetGitConfig(path, ["remote.origin.url"]);
} catch (error: unknown) {
- if (error instanceof Error) {
- throw new Error(`Failed to clone repository: ${error.message}`);
+ const baseLog = `Failed to clone repository: ${path}`;
+
+ if (env.SOURCEBOT_LOG_LEVEL !== "debug") {
+ // Avoid printing the remote URL (that may contain credentials) to logs by default.
+ throw new Error(`${baseLog}. Set environment variable SOURCEBOT_LOG_LEVEL=debug to see the full error message.`);
+ } else if (error instanceof Error) {
+ throw new Error(`${baseLog}. Reason: ${error.message}`);
} else {
- throw new Error(`Failed to clone repository: ${error}`);
+ throw new Error(`${baseLog}. Error: ${error}`);
}
}
-}
-
-
-export const fetchRepository = async (path: string, onProgress?: onProgressFn) => {
- const git = simpleGit({
- progress: onProgress,
- });
+};
+export const fetchRepository = async (
+ {
+ cloneUrl,
+ authHeader,
+ path,
+ onProgress,
+ }: {
+ cloneUrl: string,
+ authHeader?: string,
+ path: string,
+ onProgress?: onProgressFn
+ }
+) => {
try {
- await git.cwd({
+ const git = simpleGit({
+ progress: onProgress,
+ }).cwd({
path: path,
- }).fetch(
- "origin",
- [
- "--prune",
- "--progress"
- ]
- );
+ })
+
+ if (authHeader) {
+ await git.addConfig("http.extraHeader", authHeader);
+ }
+
+ await git.fetch([
+ cloneUrl,
+ "+refs/heads/*:refs/heads/*",
+ "--prune",
+ "--progress"
+ ]);
} catch (error: unknown) {
- if (error instanceof Error) {
- throw new Error(`Failed to fetch repository ${path}: ${error.message}`);
+ const baseLog = `Failed to fetch repository: ${path}`;
+ if (env.SOURCEBOT_LOG_LEVEL !== "debug") {
+ // Avoid printing the remote URL (that may contain credentials) to logs by default.
+ throw new Error(`${baseLog}. Set environment variable SOURCEBOT_LOG_LEVEL=debug to see the full error message.`);
+ } else if (error instanceof Error) {
+ throw new Error(`${baseLog}. Reason: ${error.message}`);
} else {
- throw new Error(`Failed to fetch repository ${path}: ${error}`);
+ throw new Error(`${baseLog}. Error: ${error}`);
+ }
+ } finally {
+ if (authHeader) {
+ const git = simpleGit({
+ progress: onProgress,
+ }).cwd({
+ path: path,
+ })
+
+ await git.raw(["config", "--unset", "http.extraHeader", authHeader]);
}
}
}
@@ -76,6 +125,33 @@ export const upsertGitConfig = async (path: string, gitConfig: Record {
+ const git = simpleGit({
+ progress: onProgress,
+ }).cwd(path);
+
+ try {
+ const configList = await git.listConfig();
+ const setKeys = Object.keys(configList.all);
+
+ for (const key of keys) {
+ if (setKeys.includes(key)) {
+ await git.raw(['config', '--unset', key]);
+ }
+ }
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ throw new Error(`Failed to unset git config ${path}: ${error.message}`);
+ } else {
+ throw new Error(`Failed to unset git config ${path}: ${error}`);
+ }
+ }
+}
+
/**
* Returns true if `path` is the _root_ of a git repository.
*/
diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts
index 376ed039..2b42eed2 100644
--- a/packages/backend/src/github.ts
+++ b/packages/backend/src/github.ts
@@ -30,16 +30,31 @@ export type OctokitRepository = {
size?: number,
owner: {
avatar_url: string,
+ login: string,
}
}
const isHttpError = (error: unknown, status: number): boolean => {
- return error !== null
+ return error !== null
&& typeof error === 'object'
- && 'status' in error
+ && 'status' in error
&& error.status === status;
}
+export const createOctokitFromToken = async ({ token, url }: { token?: string, url?: string }): Promise<{ octokit: Octokit, isAuthenticated: boolean }> => {
+ const octokit = new Octokit({
+ auth: token,
+ ...(url ? {
+ baseUrl: `${url}/api/v3`
+ } : {}),
+ });
+
+ return {
+ octokit,
+ isAuthenticated: !!token,
+ };
+}
+
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
const hostname = config.url ?
new URL(config.url).hostname :
@@ -48,17 +63,15 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) :
hostname === GITHUB_CLOUD_HOSTNAME ?
- env.FALLBACK_GITHUB_CLOUD_TOKEN :
- undefined;
+ env.FALLBACK_GITHUB_CLOUD_TOKEN :
+ undefined;
- const octokit = new Octokit({
- auth: token,
- ...(config.url ? {
- baseUrl: `${config.url}/api/v3`
- } : {}),
+ const { octokit, isAuthenticated } = await createOctokitFromToken({
+ token,
+ url: config.url,
});
- if (token) {
+ if (isAuthenticated) {
try {
await octokit.rest.users.getAuthenticated();
} catch (error) {
@@ -106,8 +119,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
}
if (config.users) {
- const isAuthenticated = config.token !== undefined;
- const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
+ const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, octokit, signal);
allRepos = allRepos.concat(validRepos);
notFound.users = notFoundUsers;
}
@@ -128,123 +140,69 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
logger.debug(`Found ${repos.length} total repositories.`);
return {
- validRepos: repos,
+ validRepos: repos,
notFound,
};
}
-export const shouldExcludeRepo = ({
- repo,
- include,
- exclude
-} : {
- repo: OctokitRepository,
- include?: {
- topics?: GithubConnectionConfig['topics']
- },
- exclude?: GithubConnectionConfig['exclude']
-}) => {
- let reason = '';
- const repoName = repo.full_name;
+export const getRepoCollaborators = async (owner: string, repo: string, octokit: Octokit) => {
+ try {
+ const fetchFn = () => octokit.paginate(octokit.repos.listCollaborators, {
+ owner,
+ repo,
+ per_page: 100,
+ });
- const shouldExclude = (() => {
- if (!repo.clone_url) {
- reason = 'clone_url is undefined';
- return true;
- }
-
- if (!!exclude?.forks && repo.fork) {
- reason = `\`exclude.forks\` is true`;
- return true;
- }
-
- if (!!exclude?.archived && !!repo.archived) {
- reason = `\`exclude.archived\` is true`;
- return true;
- }
-
- if (exclude?.repos) {
- if (micromatch.isMatch(repoName, exclude.repos)) {
- reason = `\`exclude.repos\` contains ${repoName}`;
- return true;
- }
- }
-
- if (exclude?.topics) {
- const configTopics = exclude.topics.map(topic => topic.toLowerCase());
- const repoTopics = repo.topics ?? [];
-
- const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
- if (matchingTopics.length > 0) {
- reason = `\`exclude.topics\` matches the following topics: ${matchingTopics.join(', ')}`;
- return true;
- }
- }
-
- if (include?.topics) {
- const configTopics = include.topics.map(topic => topic.toLowerCase());
- const repoTopics = repo.topics ?? [];
-
- const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
- if (matchingTopics.length === 0) {
- reason = `\`include.topics\` does not match any of the following topics: ${configTopics.join(', ')}`;
- return true;
- }
- }
-
- const repoSizeInBytes = repo.size ? repo.size * 1000 : undefined;
- if (exclude?.size && repoSizeInBytes) {
- const min = exclude.size.min;
- const max = exclude.size.max;
-
- if (min && repoSizeInBytes < min) {
- reason = `repo is less than \`exclude.size.min\`=${min} bytes.`;
- return true;
- }
-
- if (max && repoSizeInBytes > max) {
- reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`;
- return true;
- }
- }
-
- return false;
- })();
-
- if (shouldExclude) {
- logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`);
- return true;
+ const collaborators = await fetchWithRetry(fetchFn, `repo ${owner}/${repo}`, logger);
+ return collaborators;
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error(`Failed to fetch collaborators for repo ${owner}/${repo}.`, error);
+ throw error;
}
-
- return false;
}
-const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, octokit: Octokit, signal: AbortSignal) => {
+export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private' | 'public' = 'all', octokit: Octokit) => {
+ try {
+ const fetchFn = () => octokit.paginate(octokit.repos.listForAuthenticatedUser, {
+ per_page: 100,
+ visibility,
+ });
+
+ const repos = await fetchWithRetry(fetchFn, `authenticated user`, logger);
+ return repos;
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error(`Failed to fetch repositories for authenticated user.`, error);
+ throw error;
+ }
+}
+
+const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal) => {
const results = await Promise.allSettled(users.map(async (user) => {
try {
logger.debug(`Fetching repository info for user ${user}...`);
const { durationMs, data } = await measure(async () => {
const fetchFn = async () => {
- if (isAuthenticated) {
- return octokit.paginate(octokit.repos.listForAuthenticatedUser, {
- username: user,
- visibility: 'all',
- affiliation: 'owner',
- per_page: 100,
- request: {
- signal,
- },
- });
- } else {
- return octokit.paginate(octokit.repos.listForUser, {
- username: user,
- per_page: 100,
- request: {
- signal,
- },
- });
- }
+ let query = `user:${user}`;
+ // To include forks in the search results, we will need to add fork:true
+ // see: https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories
+ query += ' fork:true';
+ // @note: We need to use GitHub's search API here since it is the only way
+ // to get all repositories (private and public) owned by a user that supports
+ // the username as a parameter.
+ // @see: https://github.com/orgs/community/discussions/24382#discussioncomment-3243958
+ // @see: https://api.github.com/search/repositories?q=user:USERNAME
+ const searchResults = await octokit.paginate(octokit.rest.search.repos, {
+ q: query,
+ per_page: 100,
+ request: {
+ signal,
+ },
+ });
+
+ return searchResults as OctokitRepository[];
};
return fetchWithRetry(fetchFn, `user ${user}`, logger);
@@ -371,4 +329,90 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
validRepos,
notFoundRepos,
};
-}
\ No newline at end of file
+}
+
+export const shouldExcludeRepo = ({
+ repo,
+ include,
+ exclude
+}: {
+ repo: OctokitRepository,
+ include?: {
+ topics?: GithubConnectionConfig['topics']
+ },
+ exclude?: GithubConnectionConfig['exclude']
+}) => {
+ let reason = '';
+ const repoName = repo.full_name;
+
+ const shouldExclude = (() => {
+ if (!repo.clone_url) {
+ reason = 'clone_url is undefined';
+ return true;
+ }
+
+ if (!!exclude?.forks && repo.fork) {
+ reason = `\`exclude.forks\` is true`;
+ return true;
+ }
+
+ if (!!exclude?.archived && !!repo.archived) {
+ reason = `\`exclude.archived\` is true`;
+ return true;
+ }
+
+ if (exclude?.repos) {
+ if (micromatch.isMatch(repoName, exclude.repos)) {
+ reason = `\`exclude.repos\` contains ${repoName}`;
+ return true;
+ }
+ }
+
+ if (exclude?.topics) {
+ const configTopics = exclude.topics.map(topic => topic.toLowerCase());
+ const repoTopics = repo.topics ?? [];
+
+ const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
+ if (matchingTopics.length > 0) {
+ reason = `\`exclude.topics\` matches the following topics: ${matchingTopics.join(', ')}`;
+ return true;
+ }
+ }
+
+ if (include?.topics) {
+ const configTopics = include.topics.map(topic => topic.toLowerCase());
+ const repoTopics = repo.topics ?? [];
+
+ const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
+ if (matchingTopics.length === 0) {
+ reason = `\`include.topics\` does not match any of the following topics: ${configTopics.join(', ')}`;
+ return true;
+ }
+ }
+
+ const repoSizeInBytes = repo.size ? repo.size * 1000 : undefined;
+ if (exclude?.size && repoSizeInBytes) {
+ const min = exclude.size.min;
+ const max = exclude.size.max;
+
+ if (min && repoSizeInBytes < min) {
+ reason = `repo is less than \`exclude.size.min\`=${min} bytes.`;
+ return true;
+ }
+
+ if (max && repoSizeInBytes > max) {
+ reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`;
+ return true;
+ }
+ }
+
+ return false;
+ })();
+
+ if (shouldExclude) {
+ logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`);
+ return true;
+ }
+
+ return false;
+}
diff --git a/packages/backend/src/gitlab.test.ts b/packages/backend/src/gitlab.test.ts
index 49ffe433..4d9190e0 100644
--- a/packages/backend/src/gitlab.test.ts
+++ b/packages/backend/src/gitlab.test.ts
@@ -41,3 +41,30 @@ test('shouldExcludeProject returns true when the project is excluded by exclude.
})).toBe(true)
});
+test('shouldExcludeProject returns true when the project is excluded by exclude.userOwnedProjects.', () => {
+ const project = {
+ path_with_namespace: 'test/project',
+ namespace: {
+ kind: 'user',
+ }
+ } as unknown as ProjectSchema;
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ userOwnedProjects: true,
+ }
+ })).toBe(true)
+});
+
+test('shouldExcludeProject returns false when exclude.userOwnedProjects is true but project is group-owned.', () => {
+ const project = {
+ path_with_namespace: 'test/project',
+ namespace: { kind: 'group' },
+ } as unknown as ProjectSchema;
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: { userOwnedProjects: true },
+ })).toBe(false);
+});
diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts
index 79ab643b..d13692d3 100644
--- a/packages/backend/src/gitlab.ts
+++ b/packages/backend/src/gitlab.ts
@@ -222,6 +222,11 @@ export const shouldExcludeProject = ({
return true;
}
+ if (exclude?.userOwnedProjects && project.namespace.kind === 'user') {
+ reason = `\`exclude.userOwnedProjects\` is true`;
+ return true;
+ }
+
if (exclude?.projects) {
if (micromatch.isMatch(projectName, exclude.projects)) {
reason = `\`exclude.projects\` contains ${projectName}`;
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index c93622d6..93f95e0b 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -1,44 +1,37 @@
import "./instrument.js";
-import * as Sentry from "@sentry/node";
+import { PrismaClient } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { hasEntitlement, loadConfig } from '@sourcebot/shared';
import { existsSync } from 'fs';
import { mkdir } from 'fs/promises';
+import { Redis } from 'ioredis';
import path from 'path';
-import { AppContext } from "./types.js";
-import { main } from "./main.js"
-import { PrismaClient } from "@sourcebot/db";
+import { ConnectionManager } from './connectionManager.js';
+import { DEFAULT_SETTINGS } from './constants.js';
import { env } from "./env.js";
-import { createLogger } from "@sourcebot/logger";
+import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
+import { PromClient } from './promClient.js';
+import { RepoManager } from './repoManager.js';
+import { AppContext } from "./types.js";
+import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
+
const logger = createLogger('backend-entrypoint');
+const getSettings = async (configPath?: string) => {
+ if (!configPath) {
+ return DEFAULT_SETTINGS;
+ }
-// Register handler for normal exit
-process.on('exit', (code) => {
- logger.info(`Process is exiting with code: ${code}`);
-});
+ const config = await loadConfig(configPath);
-// Register handlers for abnormal terminations
-process.on('SIGINT', () => {
- logger.info('Process interrupted (SIGINT)');
- process.exit(0);
-});
+ return {
+ ...DEFAULT_SETTINGS,
+ ...config.settings,
+ }
+}
-process.on('SIGTERM', () => {
- logger.info('Process terminated (SIGTERM)');
- process.exit(0);
-});
-
-// Register handlers for uncaught exceptions and unhandled rejections
-process.on('uncaughtException', (err) => {
- logger.error(`Uncaught exception: ${err.message}`);
- process.exit(1);
-});
-
-process.on('unhandledRejection', (reason, promise) => {
- logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
- process.exit(1);
-});
const cacheDir = env.DATA_CACHE_DIR;
const reposPath = path.join(cacheDir, 'repos');
@@ -59,18 +52,62 @@ const context: AppContext = {
const prisma = new PrismaClient();
-main(prisma, context)
- .then(async () => {
- await prisma.$disconnect();
- })
- .catch(async (e) => {
- logger.error(e);
- Sentry.captureException(e);
+const redis = new Redis(env.REDIS_URL, {
+ maxRetriesPerRequest: null
+});
+redis.ping().then(() => {
+ logger.info('Connected to redis');
+}).catch((err: unknown) => {
+ logger.error('Failed to connect to redis');
+ logger.error(err);
+ process.exit(1);
+});
- await prisma.$disconnect();
- process.exit(1);
- })
- .finally(() => {
- logger.info("Shutting down...");
- });
+const promClient = new PromClient();
+const settings = await getSettings(env.CONFIG_PATH);
+
+const connectionManager = new ConnectionManager(prisma, settings, redis);
+const repoManager = new RepoManager(prisma, settings, redis, promClient, context);
+const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
+const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
+
+await repoManager.validateIndexedReposHaveShards();
+
+connectionManager.startScheduler();
+repoManager.startScheduler();
+
+if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) {
+ logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.');
+ process.exit(1);
+}
+else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) {
+ repoPermissionSyncer.startScheduler();
+ userPermissionSyncer.startScheduler();
+}
+
+const cleanup = async (signal: string) => {
+ logger.info(`Recieved ${signal}, cleaning up...`);
+
+ connectionManager.dispose();
+ repoManager.dispose();
+ repoPermissionSyncer.dispose();
+ userPermissionSyncer.dispose();
+
+ await prisma.$disconnect();
+ await redis.quit();
+}
+
+process.on('SIGINT', () => cleanup('SIGINT').finally(() => process.exit(0)));
+process.on('SIGTERM', () => cleanup('SIGTERM').finally(() => process.exit(0)));
+
+// Register handlers for uncaught exceptions and unhandled rejections
+process.on('uncaughtException', (err) => {
+ logger.error(`Uncaught exception: ${err.message}`);
+ cleanup('uncaughtException').finally(() => process.exit(1));
+});
+
+process.on('unhandledRejection', (reason, promise) => {
+ logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
+ cleanup('unhandledRejection').finally(() => process.exit(1));
+});
diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts
deleted file mode 100644
index f3cf0050..00000000
--- a/packages/backend/src/main.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { PrismaClient } from '@sourcebot/db';
-import { createLogger } from "@sourcebot/logger";
-import { AppContext } from "./types.js";
-import { DEFAULT_SETTINGS } from './constants.js';
-import { Redis } from 'ioredis';
-import { ConnectionManager } from './connectionManager.js';
-import { RepoManager } from './repoManager.js';
-import { env } from './env.js';
-import { PromClient } from './promClient.js';
-import { loadConfig } from '@sourcebot/shared';
-
-const logger = createLogger('backend-main');
-
-const getSettings = async (configPath?: string) => {
- if (!configPath) {
- return DEFAULT_SETTINGS;
- }
-
- const config = await loadConfig(configPath);
-
- return {
- ...DEFAULT_SETTINGS,
- ...config.settings,
- }
-}
-
-export const main = async (db: PrismaClient, context: AppContext) => {
- const redis = new Redis(env.REDIS_URL, {
- maxRetriesPerRequest: null
- });
- redis.ping().then(() => {
- logger.info('Connected to redis');
- }).catch((err: unknown) => {
- logger.error('Failed to connect to redis');
- logger.error(err);
- process.exit(1);
- });
-
- const settings = await getSettings(env.CONFIG_PATH);
-
- const promClient = new PromClient();
-
- const connectionManager = new ConnectionManager(db, settings, redis);
- connectionManager.registerPollingCallback();
-
- const repoManager = new RepoManager(db, settings, redis, promClient, context);
- await repoManager.validateIndexedReposHaveShards();
- await repoManager.blockingPollLoop();
-}
diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts
index 0013cd89..098f39c9 100644
--- a/packages/backend/src/repoCompileUtils.ts
+++ b/packages/backend/src/repoCompileUtils.ts
@@ -4,13 +4,15 @@ import { getGitLabReposFromConfig } from "./gitlab.js";
import { getGiteaReposFromConfig } from "./gitea.js";
import { getGerritReposFromConfig } from "./gerrit.js";
import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js";
+import { getAzureDevOpsReposFromConfig } from "./azuredevops.js";
import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi";
import { Prisma, PrismaClient } from '@sourcebot/db';
import { WithRequired } from "./types.js"
import { marshalBool } from "./utils.js";
import { createLogger } from '@sourcebot/logger';
-import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
+import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
+import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
import { RepoMetadata } from './types.js';
import path from 'path';
import { glob } from 'glob';
@@ -48,6 +50,7 @@ export const compileGithubConfig = async (
const repoDisplayName = repo.full_name;
const repoName = path.join(repoNameRoot, repoDisplayName);
const cloneUrl = new URL(repo.clone_url!);
+ const isPublic = repo.private === false;
logger.debug(`Found github repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
@@ -62,6 +65,7 @@ export const compileGithubConfig = async (
imageUrl: repo.owner.avatar_url,
isFork: repo.fork,
isArchived: !!repo.archived,
+ isPublic: isPublic,
org: {
connect: {
id: orgId,
@@ -83,7 +87,7 @@ export const compileGithubConfig = async (
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
'zoekt.archived': marshalBool(repo.archived),
'zoekt.fork': marshalBool(repo.fork),
- 'zoekt.public': marshalBool(repo.private === false),
+ 'zoekt.public': marshalBool(isPublic),
'zoekt.display-name': repoDisplayName,
},
branches: config.revisions?.branches ?? undefined,
@@ -119,6 +123,8 @@ export const compileGitlabConfig = async (
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
const cloneUrl = new URL(project.http_url_to_repo);
const isFork = project.forked_from_project !== undefined;
+ // @todo: we will need to double check whether 'internal' should also be considered public or not.
+ const isPublic = project.visibility === 'public';
const repoDisplayName = project.path_with_namespace;
const repoName = path.join(repoNameRoot, repoDisplayName);
// project.avatar_url is not directly accessible with tokens; use the avatar API endpoint if available
@@ -137,6 +143,7 @@ export const compileGitlabConfig = async (
displayName: repoDisplayName,
imageUrl: avatarUrl,
isFork: isFork,
+ isPublic: isPublic,
isArchived: !!project.archived,
org: {
connect: {
@@ -157,7 +164,7 @@ export const compileGitlabConfig = async (
'zoekt.gitlab-forks': (project.forks_count ?? 0).toString(),
'zoekt.archived': marshalBool(project.archived),
'zoekt.fork': marshalBool(isFork),
- 'zoekt.public': marshalBool(project.private === false),
+ 'zoekt.public': marshalBool(isPublic),
'zoekt.display-name': repoDisplayName,
},
branches: config.revisions?.branches ?? undefined,
@@ -195,6 +202,7 @@ export const compileGiteaConfig = async (
cloneUrl.host = configUrl.host
const repoDisplayName = repo.full_name!;
const repoName = path.join(repoNameRoot, repoDisplayName);
+ const isPublic = repo.internal === false && repo.private === false;
logger.debug(`Found gitea repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
@@ -208,6 +216,7 @@ export const compileGiteaConfig = async (
displayName: repoDisplayName,
imageUrl: repo.owner?.avatar_url,
isFork: repo.fork!,
+ isPublic: isPublic,
isArchived: !!repo.archived,
org: {
connect: {
@@ -226,7 +235,7 @@ export const compileGiteaConfig = async (
'zoekt.name': repoName,
'zoekt.archived': marshalBool(repo.archived),
'zoekt.fork': marshalBool(repo.fork!),
- 'zoekt.public': marshalBool(repo.internal === false && repo.private === false),
+ 'zoekt.public': marshalBool(isPublic),
'zoekt.display-name': repoDisplayName,
},
branches: config.revisions?.branches ?? undefined,
@@ -310,6 +319,8 @@ export const compileGerritConfig = async (
'zoekt.public': marshalBool(true),
'zoekt.display-name': repoDisplayName,
},
+ branches: config.revisions?.branches ?? undefined,
+ tags: config.revisions?.tags ?? undefined,
} satisfies RepoMetadata,
};
@@ -407,6 +418,7 @@ export const compileBitbucketConfig = async (
name: repoName,
displayName: displayName,
isFork: isFork,
+ isPublic: isPublic,
isArchived: isArchived,
org: {
connect: {
@@ -542,6 +554,7 @@ export const compileGenericGitHostConfig_file = async (
}
}
+
export const compileGenericGitHostConfig_url = async (
config: GenericGitHostConnectionConfig,
orgId: number,
@@ -603,4 +616,87 @@ export const compileGenericGitHostConfig_url = async (
repoData: [repo],
notFound,
}
-}
\ No newline at end of file
+}
+
+export const compileAzureDevOpsConfig = async (
+ config: AzureDevOpsConnectionConfig,
+ connectionId: number,
+ orgId: number,
+ db: PrismaClient,
+ abortController: AbortController) => {
+
+ const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db);
+ const azureDevOpsRepos = azureDevOpsReposResult.validRepos;
+ const notFound = azureDevOpsReposResult.notFound;
+
+ const hostUrl = config.url ?? 'https://dev.azure.com';
+ const repoNameRoot = new URL(hostUrl)
+ .toString()
+ .replace(/^https?:\/\//, '');
+
+ const repos = azureDevOpsRepos.map((repo) => {
+ if (!repo.project) {
+ throw new Error(`No project found for repository ${repo.name}`);
+ }
+
+ const repoDisplayName = `${repo.project.name}/${repo.name}`;
+ const repoName = path.join(repoNameRoot, repoDisplayName);
+ const isPublic = repo.project.visibility === ProjectVisibility.Public;
+
+ if (!repo.remoteUrl) {
+ throw new Error(`No remoteUrl found for repository ${repoDisplayName}`);
+ }
+ if (!repo.id) {
+ throw new Error(`No id found for repository ${repoDisplayName}`);
+ }
+
+ // Construct web URL for the repository
+ const webUrl = repo.webUrl || `${hostUrl}/${repo.project.name}/_git/${repo.name}`;
+
+ logger.debug(`Found Azure DevOps repo ${repoDisplayName} with webUrl: ${webUrl}`);
+
+ const record: RepoData = {
+ external_id: repo.id.toString(),
+ external_codeHostType: 'azuredevops',
+ external_codeHostUrl: hostUrl,
+ cloneUrl: webUrl,
+ webUrl: webUrl,
+ name: repoName,
+ displayName: repoDisplayName,
+ imageUrl: null,
+ isFork: !!repo.isFork,
+ isArchived: false,
+ isPublic: isPublic,
+ org: {
+ connect: {
+ id: orgId,
+ },
+ },
+ connections: {
+ create: {
+ connectionId: connectionId,
+ }
+ },
+ metadata: {
+ gitConfig: {
+ 'zoekt.web-url-type': 'azuredevops',
+ 'zoekt.web-url': webUrl,
+ 'zoekt.name': repoName,
+ 'zoekt.archived': marshalBool(false),
+ 'zoekt.fork': marshalBool(!!repo.isFork),
+ 'zoekt.public': marshalBool(isPublic),
+ 'zoekt.display-name': repoDisplayName,
+ },
+ branches: config.revisions?.branches ?? undefined,
+ tags: config.revisions?.tags ?? undefined,
+ } satisfies RepoMetadata,
+ };
+
+ return record;
+ })
+
+ return {
+ repoData: repos,
+ notFound,
+ };
+}
diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts
index 491e9d1d..89e41673 100644
--- a/packages/backend/src/repoManager.ts
+++ b/packages/backend/src/repoManager.ts
@@ -1,27 +1,19 @@
-import { Job, Queue, Worker } from 'bullmq';
-import { Redis } from 'ioredis';
-import { createLogger } from "@sourcebot/logger";
-import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
-import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
-import { AppContext, Settings, repoMetadataSchema } from "./types.js";
-import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js";
-import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js";
-import { existsSync, readdirSync, promises } from 'fs';
-import { indexGitRepository } from "./zoekt.js";
-import { PromClient } from './promClient.js';
import * as Sentry from "@sentry/node";
+import { PrismaClient, Repo, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { Job, Queue, Worker } from 'bullmq';
+import { existsSync, promises, readdirSync } from 'fs';
+import { Redis } from 'ioredis';
import { env } from './env.js';
-
-interface IRepoManager {
- validateIndexedReposHaveShards: () => Promise;
- blockingPollLoop: () => void;
- dispose: () => void;
-}
+import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
+import { PromClient } from './promClient.js';
+import { AppContext, RepoWithConnections, Settings, repoMetadataSchema } from "./types.js";
+import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, measure } from "./utils.js";
+import { indexGitRepository } from "./zoekt.js";
const REPO_INDEXING_QUEUE = 'repoIndexingQueue';
const REPO_GC_QUEUE = 'repoGarbageCollectionQueue';
-type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection })[] };
type RepoIndexingPayload = {
repo: RepoWithConnections,
}
@@ -32,11 +24,12 @@ type RepoGarbageCollectionPayload = {
const logger = createLogger('repo-manager');
-export class RepoManager implements IRepoManager {
+export class RepoManager {
private indexWorker: Worker;
private indexQueue: Queue;
private gcWorker: Worker;
private gcQueue: Queue;
+ private interval?: NodeJS.Timeout;
constructor(
private db: PrismaClient,
@@ -68,14 +61,13 @@ export class RepoManager implements IRepoManager {
this.gcWorker.on('failed', this.onGarbageCollectionJobFailed.bind(this));
}
- public async blockingPollLoop() {
- while (true) {
+ public startScheduler() {
+ logger.debug('Starting scheduler');
+ this.interval = setInterval(async () => {
await this.fetchAndScheduleRepoIndexing();
await this.fetchAndScheduleRepoGarbageCollection();
await this.fetchAndScheduleRepoTimeouts();
-
- await new Promise(resolve => setTimeout(resolve, this.settings.reindexRepoPollingIntervalMs));
- }
+ }, this.settings.reindexRepoPollingIntervalMs);
}
///////////////////////////
@@ -169,62 +161,6 @@ export class RepoManager implements IRepoManager {
}
}
-
- // TODO: do this better? ex: try using the tokens from all the connections
- // We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
- // fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
- // may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
- // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing.
- private async getCloneCredentialsForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username?: string, password: string } | undefined> {
-
- for (const { connection } of repo.connections) {
- if (connection.connectionType === 'github') {
- const config = connection.config as unknown as GithubConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
- return {
- password: token,
- }
- }
- }
-
- else if (connection.connectionType === 'gitlab') {
- const config = connection.config as unknown as GitlabConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
- return {
- username: 'oauth2',
- password: token,
- }
- }
- }
-
- else if (connection.connectionType === 'gitea') {
- const config = connection.config as unknown as GiteaConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
- return {
- password: token,
- }
- }
- }
-
- else if (connection.connectionType === 'bitbucket') {
- const config = connection.config as unknown as BitbucketConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
- const username = config.user ?? 'x-token-auth';
- return {
- username,
- password: token,
- }
- }
- }
- }
-
- return undefined;
- }
-
private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) {
const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx);
@@ -237,11 +173,27 @@ export class RepoManager implements IRepoManager {
await promises.rm(repoPath, { recursive: true, force: true });
}
- if (existsSync(repoPath) && !isReadOnly) {
- logger.info(`Fetching ${repo.displayName}...`);
+ const credentials = await getAuthCredentialsForRepo(repo, this.db);
+ const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
+ const authHeader = credentials?.authHeader ?? undefined;
- const { durationMs } = await measure(() => fetchRepository(repoPath, ({ method, stage, progress }) => {
- logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
+ if (existsSync(repoPath) && !isReadOnly) {
+ // @NOTE: in #483, we changed the cloning method s.t., we _no longer_
+ // write the clone URL (which could contain a auth token) to the
+ // `remote.origin.url` entry. For the upgrade scenario, we want
+ // to unset this key since it is no longer needed, hence this line.
+ // This will no-op if the key is already unset.
+ // @see: https://github.com/sourcebot-dev/sourcebot/pull/483
+ await unsetGitConfig(repoPath, ["remote.origin.url"]);
+
+ logger.info(`Fetching ${repo.displayName}...`);
+ const { durationMs } = await measure(() => fetchRepository({
+ cloneUrl: cloneUrlMaybeWithToken,
+ authHeader,
+ path: repoPath,
+ onProgress: ({ method, stage, progress }) => {
+ logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
+ }
}));
const fetchDuration_s = durationMs / 1000;
@@ -251,24 +203,13 @@ export class RepoManager implements IRepoManager {
} else if (!isReadOnly) {
logger.info(`Cloning ${repo.displayName}...`);
- const auth = await this.getCloneCredentialsForRepo(repo, this.db);
- const cloneUrl = new URL(repo.cloneUrl);
- if (auth) {
- // @note: URL has a weird behavior where if you set the password but
- // _not_ the username, the ":" delimiter will still be present in the
- // URL (e.g., https://:password@example.com). To get around this, if
- // we only have a password, we set the username to the password.
- // @see: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBArgJwDYwLwzAUwO4wKoBKAMgBQBEAFlFAA4QBcA9I5gB4CGAtjUpgHShOZADQBKANwAoREj412ECNhAIAJmhhl5i5WrJTQkELz5IQAcxIy+UEAGUoCAJZhLo0UA
- if (!auth.username) {
- cloneUrl.username = auth.password;
- } else {
- cloneUrl.username = auth.username;
- cloneUrl.password = auth.password;
+ const { durationMs } = await measure(() => cloneRepository({
+ cloneUrl: cloneUrlMaybeWithToken,
+ authHeader,
+ path: repoPath,
+ onProgress: ({ method, stage, progress }) => {
+ logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
}
- }
-
- const { durationMs } = await measure(() => cloneRepository(cloneUrl.toString(), repoPath, ({ method, stage, progress }) => {
- logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
}));
const cloneDuration_s = durationMs / 1000;
@@ -540,7 +481,7 @@ export class RepoManager implements IRepoManager {
public async validateIndexedReposHaveShards() {
logger.info('Validating indexed repos have shards...');
-
+
const indexedRepos = await this.db.repo.findMany({
where: {
repoIndexingStatus: RepoIndexingStatus.INDEXED
@@ -552,16 +493,15 @@ export class RepoManager implements IRepoManager {
return;
}
+ const files = readdirSync(this.ctx.indexPath);
const reposToReindex: number[] = [];
-
for (const repo of indexedRepos) {
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
-
+
// TODO: this doesn't take into account if a repo has multiple shards and only some of them are missing. To support that, this logic
// would need to know how many total shards are expected for this repo
let hasShards = false;
try {
- const files = readdirSync(this.ctx.indexPath);
hasShards = files.some(file => file.startsWith(shardPrefix));
} catch (error) {
logger.error(`Failed to read index directory ${this.ctx.indexPath}: ${error}`);
@@ -615,6 +555,9 @@ export class RepoManager implements IRepoManager {
}
public async dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
this.indexWorker.close();
this.indexQueue.close();
this.gcQueue.close();
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 58674f49..2ea42d04 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -1,3 +1,4 @@
+import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
import { z } from "zod";
@@ -50,4 +51,14 @@ export type DeepPartial = T extends object ? {
} : T;
// @see: https://stackoverflow.com/a/69328045
-export type WithRequired = T & { [P in K]-?: T[P] };
\ No newline at end of file
+export type WithRequired = T & { [P in K]-?: T[P] };
+
+export type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection })[] };
+
+
+export type RepoAuthCredentials = {
+ hostUrl?: string;
+ token: string;
+ cloneUrlWithToken?: string;
+ authHeader?: string;
+}
\ No newline at end of file
diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts
index 3245828d..e6ac5f93 100644
--- a/packages/backend/src/utils.ts
+++ b/packages/backend/src/utils.ts
@@ -1,10 +1,11 @@
import { Logger } from "winston";
-import { AppContext } from "./types.js";
+import { AppContext, RepoAuthCredentials, RepoWithConnections } from "./types.js";
import path from 'path';
import { PrismaClient, Repo } from "@sourcebot/db";
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";
+import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
export const measure = async (cb: () => Promise) => {
const start = Date.now();
@@ -116,4 +117,127 @@ export const fetchWithRetry = async (
throw e;
}
}
-}
\ No newline at end of file
+}
+
+// TODO: do this better? ex: try using the tokens from all the connections
+// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
+// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
+// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
+// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing.
+export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: PrismaClient, logger?: Logger): Promise => {
+ for (const { connection } of repo.connections) {
+ if (connection.connectionType === 'github') {
+ const config = connection.config as unknown as GithubConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
+ return {
+ hostUrl: config.url,
+ token,
+ cloneUrlWithToken: createGitCloneUrlWithToken(
+ repo.cloneUrl,
+ {
+ password: token,
+ }
+ ),
+ }
+ }
+ } else if (connection.connectionType === 'gitlab') {
+ const config = connection.config as unknown as GitlabConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
+ return {
+ hostUrl: config.url,
+ token,
+ cloneUrlWithToken: createGitCloneUrlWithToken(
+ repo.cloneUrl,
+ {
+ username: 'oauth2',
+ password: token
+ }
+ ),
+ }
+ }
+ } else if (connection.connectionType === 'gitea') {
+ const config = connection.config as unknown as GiteaConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
+ return {
+ hostUrl: config.url,
+ token,
+ cloneUrlWithToken: createGitCloneUrlWithToken(
+ repo.cloneUrl,
+ {
+ password: token
+ }
+ ),
+ }
+ }
+ } else if (connection.connectionType === 'bitbucket') {
+ const config = connection.config as unknown as BitbucketConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
+ const username = config.user ?? 'x-token-auth';
+ return {
+ hostUrl: config.url,
+ token,
+ cloneUrlWithToken: createGitCloneUrlWithToken(
+ repo.cloneUrl,
+ {
+ username,
+ password: token
+ }
+ ),
+ }
+ }
+ } else if (connection.connectionType === 'azuredevops') {
+ const config = connection.config as unknown as AzureDevOpsConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
+
+ // For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default
+ // to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token
+ // appropriately in the header. To do this, we set the authHeader field here
+ if (config.deploymentType === 'server') {
+ return {
+ hostUrl: config.url,
+ token,
+ authHeader: "Authorization: Basic " + Buffer.from(`:${token}`).toString('base64')
+ }
+ } else {
+ return {
+ hostUrl: config.url,
+ token,
+ cloneUrlWithToken: createGitCloneUrlWithToken(
+ repo.cloneUrl,
+ {
+ // @note: If we don't provide a username, the password will be set as the username. This seems to work
+ // for ADO cloud but not for ADO server. To fix this, we set a placeholder username to ensure the password
+ // is set correctly
+ username: 'user',
+ password: token
+ }
+ ),
+ }
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
+
+const createGitCloneUrlWithToken = (cloneUrl: string, credentials: { username?: string, password: string }) => {
+ const url = new URL(cloneUrl);
+ // @note: URL has a weird behavior where if you set the password but
+ // _not_ the username, the ":" delimiter will still be present in the
+ // URL (e.g., https://:password@example.com). To get around this, if
+ // we only have a password, we set the username to the password.
+ // @see: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBArgJwDYwLwzAUwO4wKoBKAMgBQBEAFlFAA4QBcA9I5gB4CGAtjUpgHShOZADQBKANwAoREj412ECNhAIAJmhhl5i5WrJTQkELz5IQAcxIy+UEAGUoCAJZhLo0UA
+ if (!credentials.username) {
+ url.username = credentials.password;
+ } else {
+ url.username = credentials.username;
+ url.password = credentials.password;
+ }
+ return url.toString();
+}
diff --git a/packages/backend/src/zoekt.ts b/packages/backend/src/zoekt.ts
index 86a191e4..076820c9 100644
--- a/packages/backend/src/zoekt.ts
+++ b/packages/backend/src/zoekt.ts
@@ -63,7 +63,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
`-index ${ctx.indexPath}`,
`-max_trigram_count ${settings.maxTrigramCount}`,
`-file_limit ${settings.maxFileSize}`,
- `-branches ${revisions.join(',')}`,
+ `-branches "${revisions.join(',')}"`,
`-tenant_id ${repo.orgId}`,
`-repo_id ${repo.id}`,
`-shard_prefix ${shardPrefix}`,
@@ -76,6 +76,20 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
reject(error);
return;
}
+
+ if (stdout) {
+ stdout.split('\n').filter(line => line.trim()).forEach(line => {
+ logger.info(line);
+ });
+ }
+ if (stderr) {
+ stderr.split('\n').filter(line => line.trim()).forEach(line => {
+ // TODO: logging as regular info here and not error because non error logs are being
+ // streamed in stderr and incorrectly being logged as errors at a high level
+ logger.info(line);
+ });
+ }
+
resolve({
stdout,
stderr
diff --git a/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql b/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql
new file mode 100644
index 00000000..38d64354
--- /dev/null
+++ b/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql
@@ -0,0 +1,23 @@
+-- CreateEnum
+CREATE TYPE "ChatVisibility" AS ENUM ('PRIVATE', 'PUBLIC');
+
+-- CreateTable
+CREATE TABLE "Chat" (
+ "id" TEXT NOT NULL,
+ "name" TEXT,
+ "createdById" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "orgId" INTEGER NOT NULL,
+ "visibility" "ChatVisibility" NOT NULL DEFAULT 'PRIVATE',
+ "isReadonly" BOOLEAN NOT NULL DEFAULT false,
+ "messages" JSONB NOT NULL,
+
+ CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Chat" ADD CONSTRAINT "Chat_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Chat" ADD CONSTRAINT "Chat_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20250919221836_add_indexes_to_repo_tables/migration.sql b/packages/db/prisma/migrations/20250919221836_add_indexes_to_repo_tables/migration.sql
new file mode 100644
index 00000000..2b67f1f9
--- /dev/null
+++ b/packages/db/prisma/migrations/20250919221836_add_indexes_to_repo_tables/migration.sql
@@ -0,0 +1,5 @@
+-- CreateIndex
+CREATE INDEX "Repo_orgId_idx" ON "Repo"("orgId");
+
+-- CreateIndex
+CREATE INDEX "RepoToConnection_repoId_connectionId_idx" ON "RepoToConnection"("repoId", "connectionId");
diff --git a/packages/db/prisma/migrations/20250920232318_add_permission_sync_tables/migration.sql b/packages/db/prisma/migrations/20250920232318_add_permission_sync_tables/migration.sql
new file mode 100644
index 00000000..9e921c6d
--- /dev/null
+++ b/packages/db/prisma/migrations/20250920232318_add_permission_sync_tables/migration.sql
@@ -0,0 +1,59 @@
+-- CreateEnum
+CREATE TYPE "RepoPermissionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
+
+-- CreateEnum
+CREATE TYPE "UserPermissionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
+
+-- AlterTable
+ALTER TABLE "Repo" ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "permissionSyncedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "permissionSyncedAt" TIMESTAMP(3);
+
+-- CreateTable
+CREATE TABLE "RepoPermissionSyncJob" (
+ "id" TEXT NOT NULL,
+ "status" "RepoPermissionSyncJobStatus" NOT NULL DEFAULT 'PENDING',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "completedAt" TIMESTAMP(3),
+ "errorMessage" TEXT,
+ "repoId" INTEGER NOT NULL,
+
+ CONSTRAINT "RepoPermissionSyncJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserPermissionSyncJob" (
+ "id" TEXT NOT NULL,
+ "status" "UserPermissionSyncJobStatus" NOT NULL DEFAULT 'PENDING',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "completedAt" TIMESTAMP(3),
+ "errorMessage" TEXT,
+ "userId" TEXT NOT NULL,
+
+ CONSTRAINT "UserPermissionSyncJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserToRepoPermission" (
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "repoId" INTEGER NOT NULL,
+ "userId" TEXT NOT NULL,
+
+ CONSTRAINT "UserToRepoPermission_pkey" PRIMARY KEY ("repoId","userId")
+);
+
+-- AddForeignKey
+ALTER TABLE "RepoPermissionSyncJob" ADD CONSTRAINT "RepoPermissionSyncJob_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserPermissionSyncJob" ADD CONSTRAINT "UserPermissionSyncJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index bddc8473..bdebbc69 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -35,29 +35,35 @@ enum StripeSubscriptionStatus {
INACTIVE
}
+enum ChatVisibility {
+ PRIVATE
+ PUBLIC
+}
+
model Repo {
- id Int @id @default(autoincrement())
- name String
- displayName String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- /// When the repo was last indexed successfully.
- indexedAt DateTime?
+ id Int @id @default(autoincrement())
+ name String /// Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot)
+ displayName String? /// Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ indexedAt DateTime? /// When the repo was last indexed successfully.
isFork Boolean
isArchived Boolean
- metadata Json // For schema see repoMetadataSchema in packages/backend/src/types.ts
+ isPublic Boolean @default(false)
+ metadata Json /// For schema see repoMetadataSchema in packages/backend/src/types.ts
cloneUrl String
webUrl String?
connections RepoToConnection[]
imageUrl String?
- repoIndexingStatus RepoIndexingStatus @default(NEW)
+ repoIndexingStatus RepoIndexingStatus @default(NEW)
- // The id of the repo in the external service
- external_id String
- // The type of the external service (e.g., github, gitlab, etc.)
- external_codeHostType String
- // The base url of the external service (e.g., https://github.com)
- external_codeHostUrl String
+ permittedUsers UserToRepoPermission[]
+ permissionSyncJobs RepoPermissionSyncJob[]
+ permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
+
+ external_id String /// The id of the repo in the external service
+ external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
+ external_codeHostUrl String /// The base url of the external service (e.g., https://github.com)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
@@ -65,14 +71,35 @@ model Repo {
searchContexts SearchContext[]
@@unique([external_id, external_codeHostUrl, orgId])
+ @@index([orgId])
+}
+
+enum RepoPermissionSyncJobStatus {
+ PENDING
+ IN_PROGRESS
+ COMPLETED
+ FAILED
+}
+
+model RepoPermissionSyncJob {
+ id String @id @default(cuid())
+ status RepoPermissionSyncJobStatus @default(PENDING)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ completedAt DateTime?
+
+ errorMessage String?
+
+ repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
+ repoId Int
}
model SearchContext {
id Int @id @default(autoincrement())
- name String
+ name String
description String?
- repos Repo[]
+ repos Repo[]
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
@@ -113,6 +140,7 @@ model RepoToConnection {
repoId Int
@@id([connectionId, repoId])
+ @@index([repoId, connectionId])
}
model Invite {
@@ -141,7 +169,7 @@ model AccountRequest {
createdAt DateTime @default(now())
- requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
+ requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
requestedById String @unique
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@ -163,7 +191,7 @@ model Org {
apiKeys ApiKey[]
isOnboarded Boolean @default(false)
imageUrl String?
- metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
+ metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
memberApprovalRequired Boolean @default(true)
@@ -173,16 +201,18 @@ model Org {
/// List of pending invites to this organization
invites Invite[]
-
+
/// The invite id for this organization
inviteLinkEnabled Boolean @default(false)
- inviteLinkId String?
+ inviteLinkId String?
audits Audit[]
accountRequests AccountRequest[]
searchContexts SearchContext[]
+
+ chats Chat[]
}
enum OrgRole {
@@ -221,63 +251,98 @@ model Secret {
}
model ApiKey {
- name String
- hash String @id @unique
+ name String
+ hash String @id @unique
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
lastUsedAt DateTime?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
- createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
+ createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById String
-
}
model Audit {
- id String @id @default(cuid())
+ id String @id @default(cuid())
timestamp DateTime @default(now())
-
- action String
- actorId String
- actorType String
- targetId String
- targetType String
+
+ action String
+ actorId String
+ actorType String
+ targetId String
+ targetType String
sourcebotVersion String
- metadata Json?
+ metadata Json?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
@@index([actorId, actorType, targetId, targetType, orgId])
-
// Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
-
// Fast path for analytics queries for a specific user
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
}
// @see : https://authjs.dev/concepts/database-models#user
model User {
- id String @id @default(cuid())
+ id String @id @default(cuid())
name String?
- email String? @unique
+ email String? @unique
hashedPassword String?
emailVerified DateTime?
image String?
accounts Account[]
orgs UserToOrg[]
accountRequest AccountRequest?
+ accessibleRepos UserToRepoPermission[]
/// List of pending invites that the user has created
invites Invite[]
apiKeys ApiKey[]
+ chats Chat[]
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+
+ permissionSyncJobs UserPermissionSyncJob[]
+ permissionSyncedAt DateTime?
+}
+
+enum UserPermissionSyncJobStatus {
+ PENDING
+ IN_PROGRESS
+ COMPLETED
+ FAILED
+}
+
+model UserPermissionSyncJob {
+ id String @id @default(cuid())
+ status UserPermissionSyncJobStatus @default(PENDING)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ completedAt DateTime?
+
+ errorMessage String?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String
+}
+
+model UserToRepoPermission {
+ createdAt DateTime @default(now())
+
+ repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
+ repoId Int
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String
+
+ @@id([repoId, userId])
}
// @see : https://authjs.dev/concepts/database-models#account
@@ -311,3 +376,23 @@ model VerificationToken {
@@unique([identifier, token])
}
+
+model Chat {
+ id String @id @default(cuid())
+
+ name String?
+
+ createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
+ createdById String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ orgId Int
+
+ visibility ChatVisibility @default(PRIVATE)
+ isReadonly Boolean @default(false)
+
+ messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
+}
diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts
index 8e72063a..499a3770 100644
--- a/packages/db/tools/scriptRunner.ts
+++ b/packages/db/tools/scriptRunner.ts
@@ -4,6 +4,8 @@ import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connect
import { injectAuditData } from "./scripts/inject-audit-data";
import { confirmAction } from "./utils";
import { createLogger } from "@sourcebot/logger";
+import { injectRepoData } from "./scripts/inject-repo-data";
+import { testRepoQueryPerf } from "./scripts/test-repo-query-perf";
export interface Script {
run: (prisma: PrismaClient) => Promise;
@@ -12,6 +14,8 @@ export interface Script {
export const scripts: Record = {
"migrate-duplicate-connections": migrateDuplicateConnections,
"inject-audit-data": injectAuditData,
+ "inject-repo-data": injectRepoData,
+ "test-repo-query-perf": testRepoQueryPerf,
}
const parser = new ArgumentParser();
diff --git a/packages/db/tools/scripts/inject-repo-data.ts b/packages/db/tools/scripts/inject-repo-data.ts
new file mode 100644
index 00000000..209880f3
--- /dev/null
+++ b/packages/db/tools/scripts/inject-repo-data.ts
@@ -0,0 +1,64 @@
+import { Script } from "../scriptRunner";
+import { PrismaClient } from "../../dist";
+import { createLogger } from "@sourcebot/logger";
+
+const logger = createLogger('inject-repo-data');
+
+const NUM_REPOS = 100000;
+
+export const injectRepoData: Script = {
+ run: async (prisma: PrismaClient) => {
+ const orgId = 1;
+
+ // Check if org exists
+ const org = await prisma.org.findUnique({
+ where: { id: orgId }
+ });
+
+ if (!org) {
+ await prisma.org.create({
+ data: {
+ id: orgId,
+ name: 'Test Org',
+ domain: 'test-org.com'
+ }
+ });
+ }
+
+ const connection = await prisma.connection.create({
+ data: {
+ orgId,
+ name: 'test-connection',
+ connectionType: 'github',
+ config: {}
+ }
+ });
+
+
+ logger.info(`Creating ${NUM_REPOS} repos...`);
+
+ for (let i = 0; i < NUM_REPOS; i++) {
+ await prisma.repo.create({
+ data: {
+ name: `test-repo-${i}`,
+ isFork: false,
+ isArchived: false,
+ metadata: {},
+ cloneUrl: `https://github.com/test-org/test-repo-${i}`,
+ webUrl: `https://github.com/test-org/test-repo-${i}`,
+ orgId,
+ external_id: `test-repo-${i}`,
+ external_codeHostType: 'github',
+ external_codeHostUrl: 'https://github.com',
+ connections: {
+ create: {
+ connectionId: connection.id,
+ }
+ }
+ }
+ });
+ }
+
+ logger.info(`Created ${NUM_REPOS} repos.`);
+ }
+};
\ No newline at end of file
diff --git a/packages/db/tools/scripts/test-repo-query-perf.ts b/packages/db/tools/scripts/test-repo-query-perf.ts
new file mode 100644
index 00000000..ee07d14f
--- /dev/null
+++ b/packages/db/tools/scripts/test-repo-query-perf.ts
@@ -0,0 +1,28 @@
+import { Script } from "../scriptRunner";
+import { PrismaClient } from "../../dist";
+import { createLogger } from "@sourcebot/logger";
+
+const logger = createLogger('test-repo-query-perf');
+
+export const testRepoQueryPerf: Script = {
+ run: async (prisma: PrismaClient) => {
+
+
+ const start = Date.now();
+ const allRepos = await prisma.repo.findMany({
+ where: {
+ orgId: 1,
+ },
+ include: {
+ connections: {
+ include: {
+ connection: true,
+ }
+ }
+ }
+ });
+
+ const durationMs = Date.now() - start;
+ logger.info(`Found ${allRepos.length} repos in ${durationMs}ms`);
+ }
+};
\ No newline at end of file
diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md
index b856337f..aca038a0 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.6] - 2025-09-26
+- Fix `linkedConnections is required` schema error.
+
+## [1.0.5] - 2025-09-15
+
+### Changed
+- Updated API client to match the latest Sourcebot release. [#356](https://github.com/sourcebot-dev/sourcebot/pull/356)
+
+## [1.0.4] - 2025-08-04
+
+### Fixed
+- Fixed issue where console logs were resulting in "unexpected token" errors on the MCP client. [#429](https://github.com/sourcebot-dev/sourcebot/pull/429)
+
## [1.0.3] - 2025-06-18
### Changed
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index 27808b13..0c64bdc3 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -207,18 +207,10 @@ Sourcebot supports the following code hosts:
- [Gitea](https://docs.sourcebot.dev/docs/connections/gitea)
- [Gerrit](https://docs.sourcebot.dev/docs/connections/gerrit)
-| Don't see your code host? Open a [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas).
+| Don't see your code host? Open a [feature request](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md).
## Future Work
### Semantic Search
Currently, Sourcebot only supports regex-based code search (powered by [zoekt](https://github.com/sourcegraph/zoekt) under the hood). It is great for scenarios when the agent is searching for is something that is super precise and well-represented in the source code (e.g., a specific function name, a error string, etc.). It is not-so-great for _fuzzy_ searches where the objective is to find some loosely defined _category_ or _concept_ in the code (e.g., find code that verifies JWT tokens). The LLM can approximate this by crafting regex searches that attempt to capture a concept (e.g., it might try a query like `"jwt|token|(verify|validate).*(jwt|token)"`), but often yields sub-optimal search results that aren't related. Tools like Cursor solve this with [embedding models](https://docs.cursor.com/context/codebase-indexing) to capture the semantic meaning of code, allowing for LLMs to search using natural language. We would like to extend Sourcebot to support semantic search and expose this capability over MCP as a tool (e.g., `semantic_search_code` tool). [GitHub Discussion](https://github.com/sourcebot-dev/sourcebot/discussions/297)
-
-### Code Navigation
-
-Another idea is to allow LLMs to traverse abstract syntax trees (ASTs) of a codebase to enable reliable code navigation. This could be packaged as tools like `goto_definition`, `find_all_references`, etc., which could be useful for LLMs to get additional code context. [GitHub Discussion](https://github.com/sourcebot-dev/sourcebot/discussions/296)
-
-### Got an idea?
-
-Open up a [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests)!
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 969a465d..b11c8361 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@sourcebot/mcp",
- "version": "1.0.3",
+ "version": "1.0.6",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts
index c7d7d230..3754c605 100644
--- a/packages/mcp/src/client.ts
+++ b/packages/mcp/src/client.ts
@@ -4,7 +4,6 @@ import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, Search
import { isServiceError } from './utils.js';
export const search = async (request: SearchRequest): Promise => {
- console.debug(`Executing search request: ${JSON.stringify(request, null, 2)}`);
const result = await fetch(`${env.SOURCEBOT_HOST}/api/search`, {
method: 'POST',
headers: {
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index dfd0ebe2..4411b580 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -75,8 +75,6 @@ server.tool(
query += ` case:no`;
}
- console.debug(`Executing search request: ${query}`);
-
const response = await search({
query,
matches: env.DEFAULT_MATCHES,
@@ -163,10 +161,10 @@ server.tool(
};
}
- const content: TextContent[] = response.repos.map(repo => {
+ const content: TextContent[] = response.map(repo => {
return {
type: "text",
- text: `id: ${repo.name}\nurl: ${repo.webUrl}`,
+ text: `id: ${repo.repoName}\nurl: ${repo.webUrl}`,
}
});
@@ -214,7 +212,6 @@ server.tool(
const runServer = async () => {
const transport = new StdioServerTransport();
await server.connect(transport);
- console.info('Sourcebot MCP server ready');
}
runServer().catch((error) => {
diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts
index ad94b7dc..40736b59 100644
--- a/packages/mcp/src/schemas.ts
+++ b/packages/mcp/src/schemas.ts
@@ -92,16 +92,30 @@ export const searchResponseSchema = z.object({
isBranchFilteringEnabled: z.boolean(),
});
-export const repositorySchema = z.object({
- name: z.string(),
- branches: z.array(z.string()),
+enum RepoIndexingStatus {
+ NEW = 'NEW',
+ IN_INDEX_QUEUE = 'IN_INDEX_QUEUE',
+ INDEXING = 'INDEXING',
+ INDEXED = 'INDEXED',
+ FAILED = 'FAILED',
+ IN_GC_QUEUE = 'IN_GC_QUEUE',
+ GARBAGE_COLLECTING = 'GARBAGE_COLLECTING',
+ GARBAGE_COLLECTION_FAILED = 'GARBAGE_COLLECTION_FAILED'
+}
+
+export const repositoryQuerySchema = z.object({
+ codeHostType: z.string(),
+ repoId: z.number(),
+ repoName: z.string(),
+ repoDisplayName: z.string().optional(),
+ repoCloneUrl: z.string(),
webUrl: z.string().optional(),
- rawConfig: z.record(z.string(), z.string()).optional(),
+ imageUrl: z.string().optional(),
+ indexedAt: z.coerce.date().optional(),
+ repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
});
-export const listRepositoriesResponseSchema = z.object({
- repos: z.array(repositorySchema),
-});
+export const listRepositoriesResponseSchema = repositoryQuerySchema.array();
export const fileSourceRequestSchema = z.object({
fileName: z.string(),
diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts
index f789c8c1..9c858fe5 100644
--- a/packages/mcp/src/types.ts
+++ b/packages/mcp/src/types.ts
@@ -22,7 +22,6 @@ export type SearchResultChunk = SearchResultFile["chunks"][number];
export type SearchSymbol = z.infer;
export type ListRepositoriesResponse = z.infer;
-export type Repository = ListRepositoriesResponse["repos"][number];
export type FileSourceRequest = z.infer;
export type FileSourceResponse = z.infer;
diff --git a/packages/schemas/src/v3/azuredevops.schema.ts b/packages/schemas/src/v3/azuredevops.schema.ts
new file mode 100644
index 00000000..3b36fbed
--- /dev/null
+++ b/packages/schemas/src/v3/azuredevops.schema.ts
@@ -0,0 +1,205 @@
+// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
+const schema = {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+} as const;
+export { schema as azuredevopsSchema };
\ No newline at end of file
diff --git a/packages/schemas/src/v3/azuredevops.type.ts b/packages/schemas/src/v3/azuredevops.type.ts
new file mode 100644
index 00000000..b6ef68da
--- /dev/null
+++ b/packages/schemas/src/v3/azuredevops.type.ts
@@ -0,0 +1,89 @@
+// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
+
+export interface AzureDevOpsConnectionConfig {
+ /**
+ * Azure DevOps Configuration
+ */
+ type: "azuredevops";
+ /**
+ * A Personal Access Token (PAT).
+ */
+ token:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.
+ */
+ url?: string;
+ /**
+ * The type of Azure DevOps deployment
+ */
+ deploymentType: "cloud" | "server";
+ /**
+ * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
+ */
+ useTfsPath?: boolean;
+ /**
+ * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property.
+ */
+ orgs?: string[];
+ /**
+ * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server.
+ */
+ projects?: string[];
+ /**
+ * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'.
+ */
+ repos?: string[];
+ exclude?: {
+ /**
+ * Exclude disabled repositories from syncing.
+ */
+ disabled?: boolean;
+ /**
+ * List of repositories to exclude from syncing. Glob patterns are supported.
+ */
+ repos?: string[];
+ /**
+ * List of projects to exclude from syncing. Glob patterns are supported.
+ */
+ projects?: string[];
+ /**
+ * Exclude repositories based on their size.
+ */
+ size?: {
+ /**
+ * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing.
+ */
+ min?: number;
+ /**
+ * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing.
+ */
+ max?: number;
+ };
+ };
+ revisions?: GitRevisions;
+}
+/**
+ * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.
+ */
+export interface GitRevisions {
+ /**
+ * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.
+ */
+ branches?: string[];
+ /**
+ * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.
+ */
+ tags?: string[];
+}
diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts
index d0a72c72..e0bcbc48 100644
--- a/packages/schemas/src/v3/connection.schema.ts
+++ b/packages/schemas/src/v3/connection.schema.ts
@@ -342,6 +342,11 @@ const schema = {
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
@@ -637,6 +642,47 @@ const schema = {
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
@@ -822,6 +868,209 @@ const schema = {
},
"additionalProperties": false
},
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+ },
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts
index d1d2bc18..df31c465 100644
--- a/packages/schemas/src/v3/connection.type.ts
+++ b/packages/schemas/src/v3/connection.type.ts
@@ -6,6 +6,7 @@ export type ConnectionConfig =
| GiteaConnectionConfig
| GerritConnectionConfig
| BitbucketConnectionConfig
+ | AzureDevOpsConnectionConfig
| GenericGitHostConnectionConfig;
export interface GithubConnectionConfig {
@@ -153,6 +154,10 @@ export interface GitlabConnectionConfig {
* Exclude archived projects from syncing.
*/
archived?: boolean;
+ /**
+ * Exclude user-owned projects from syncing.
+ */
+ userOwnedProjects?: boolean;
/**
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
*/
@@ -244,6 +249,7 @@ export interface GerritConnectionConfig {
*/
hidden?: boolean;
};
+ revisions?: GitRevisions;
}
export interface BitbucketConnectionConfig {
/**
@@ -306,6 +312,80 @@ export interface BitbucketConnectionConfig {
};
revisions?: GitRevisions;
}
+export interface AzureDevOpsConnectionConfig {
+ /**
+ * Azure DevOps Configuration
+ */
+ type: "azuredevops";
+ /**
+ * A Personal Access Token (PAT).
+ */
+ token:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.
+ */
+ url?: string;
+ /**
+ * The type of Azure DevOps deployment
+ */
+ deploymentType: "cloud" | "server";
+ /**
+ * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
+ */
+ useTfsPath?: boolean;
+ /**
+ * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property.
+ */
+ orgs?: string[];
+ /**
+ * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server.
+ */
+ projects?: string[];
+ /**
+ * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'.
+ */
+ repos?: string[];
+ exclude?: {
+ /**
+ * Exclude disabled repositories from syncing.
+ */
+ disabled?: boolean;
+ /**
+ * List of repositories to exclude from syncing. Glob patterns are supported.
+ */
+ repos?: string[];
+ /**
+ * List of projects to exclude from syncing. Glob patterns are supported.
+ */
+ projects?: string[];
+ /**
+ * Exclude repositories based on their size.
+ */
+ size?: {
+ /**
+ * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing.
+ */
+ min?: number;
+ /**
+ * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing.
+ */
+ max?: number;
+ };
+ };
+ revisions?: GitRevisions;
+}
export interface GenericGitHostConnectionConfig {
/**
* Generic Git host configuration
diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts
index b8b99e76..8733ba8d 100644
--- a/packages/schemas/src/v3/gerrit.schema.ts
+++ b/packages/schemas/src/v3/gerrit.schema.ts
@@ -58,6 +58,47 @@ const schema = {
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
diff --git a/packages/schemas/src/v3/gerrit.type.ts b/packages/schemas/src/v3/gerrit.type.ts
index 752a63b3..01462e3a 100644
--- a/packages/schemas/src/v3/gerrit.type.ts
+++ b/packages/schemas/src/v3/gerrit.type.ts
@@ -27,4 +27,18 @@ export interface GerritConnectionConfig {
*/
hidden?: boolean;
};
+ revisions?: GitRevisions;
+}
+/**
+ * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.
+ */
+export interface GitRevisions {
+ /**
+ * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.
+ */
+ branches?: string[];
+ /**
+ * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.
+ */
+ tags?: string[];
}
diff --git a/packages/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts
index 891ca4eb..72d367e1 100644
--- a/packages/schemas/src/v3/gitlab.schema.ts
+++ b/packages/schemas/src/v3/gitlab.schema.ts
@@ -125,6 +125,11 @@ const schema = {
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
diff --git a/packages/schemas/src/v3/gitlab.type.ts b/packages/schemas/src/v3/gitlab.type.ts
index f5a293ce..f25193b8 100644
--- a/packages/schemas/src/v3/gitlab.type.ts
+++ b/packages/schemas/src/v3/gitlab.type.ts
@@ -56,6 +56,10 @@ export interface GitlabConnectionConfig {
* Exclude archived projects from syncing.
*/
archived?: boolean;
+ /**
+ * Exclude user-owned projects from syncing.
+ */
+ userOwnedProjects?: boolean;
/**
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
*/
diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts
index 1eafaffe..38ec2f0a 100644
--- a/packages/schemas/src/v3/index.schema.ts
+++ b/packages/schemas/src/v3/index.schema.ts
@@ -68,6 +68,16 @@ const schema = {
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
+ },
+ "experiment_repoDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
+ },
+ "experiment_userDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
}
},
"additionalProperties": false
@@ -91,6 +101,13 @@ const schema = {
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -104,14 +121,18 @@ const schema = {
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
}
},
@@ -183,6 +204,16 @@ const schema = {
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
+ },
+ "experiment_repoDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
+ },
+ "experiment_userDrivenPermissionSyncIntervalMs": {
+ "type": "number",
+ "description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
+ "minimum": 1
}
},
"additionalProperties": false
@@ -210,6 +241,13 @@ const schema = {
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -223,14 +261,18 @@ const schema = {
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
}
},
@@ -583,6 +625,11 @@ const schema = {
"default": false,
"description": "Exclude archived projects from syncing."
},
+ "userOwnedProjects": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude user-owned projects from syncing."
+ },
"projects": {
"type": "array",
"items": {
@@ -878,6 +925,47 @@ const schema = {
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
}
},
"required": [
@@ -1063,6 +1151,209 @@ const schema = {
},
"additionalProperties": false
},
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "AzureDevOpsConnectionConfig",
+ "properties": {
+ "type": {
+ "const": "azuredevops",
+ "description": "Azure DevOps Configuration"
+ },
+ "token": {
+ "description": "A Personal Access Token (PAT).",
+ "examples": [
+ {
+ "secret": "SECRET_KEY"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "url": {
+ "type": "string",
+ "format": "url",
+ "default": "https://dev.azure.com",
+ "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.",
+ "examples": [
+ "https://dev.azure.com",
+ "https://azuredevops.example.com"
+ ],
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "deploymentType": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "server"
+ ],
+ "description": "The type of Azure DevOps deployment"
+ },
+ "useTfsPath": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)."
+ },
+ "orgs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org"
+ ]
+ ],
+ "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project",
+ "my-collection/my-project"
+ ]
+ ],
+ "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$"
+ },
+ "default": [],
+ "examples": [
+ [
+ "my-org/my-project/my-repo"
+ ]
+ ],
+ "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'."
+ },
+ "exclude": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude disabled repositories from syncing."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of repositories to exclude from syncing. Glob patterns are supported."
+ },
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "List of projects to exclude from syncing. Glob patterns are supported."
+ },
+ "size": {
+ "type": "object",
+ "description": "Exclude repositories based on their size.",
+ "properties": {
+ "min": {
+ "type": "integer",
+ "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
+ },
+ "max": {
+ "type": "integer",
+ "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "revisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "type",
+ "token",
+ "deploymentType"
+ ],
+ "additionalProperties": false
+ },
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
@@ -1135,6 +1426,2852 @@ const schema = {
}
},
"additionalProperties": false
+ },
+ "models": {
+ "type": "array",
+ "description": "Defines a collection of language models that are available to Sourcebot.",
+ "items": {
+ "type": "object",
+ "title": "LanguageModel",
+ "definitions": {
+ "AmazonBedrockLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AzureLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "DeepSeekLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleGenerativeAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexAnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "MistralLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAICompatibleLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ "OpenRouterLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "XaiLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
}
},
"additionalProperties": false
diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts
index 1390bf4e..4e8982dc 100644
--- a/packages/schemas/src/v3/index.type.ts
+++ b/packages/schemas/src/v3/index.type.ts
@@ -10,7 +10,21 @@ export type ConnectionConfig =
| GiteaConnectionConfig
| GerritConnectionConfig
| BitbucketConnectionConfig
+ | AzureDevOpsConnectionConfig
| GenericGitHostConnectionConfig;
+export type LanguageModel =
+ | AmazonBedrockLanguageModel
+ | AnthropicLanguageModel
+ | AzureLanguageModel
+ | DeepSeekLanguageModel
+ | GoogleGenerativeAILanguageModel
+ | GoogleVertexAnthropicLanguageModel
+ | GoogleVertexLanguageModel
+ | MistralLanguageModel
+ | OpenAILanguageModel
+ | OpenAICompatibleLanguageModel
+ | OpenRouterLanguageModel
+ | XaiLanguageModel;
export interface SourcebotConfig {
$schema?: string;
@@ -27,6 +41,10 @@ export interface SourcebotConfig {
connections?: {
[k: string]: ConnectionConfig;
};
+ /**
+ * Defines a collection of language models that are available to Sourcebot.
+ */
+ models?: LanguageModel[];
}
/**
* Defines the global settings for Sourcebot.
@@ -84,6 +102,14 @@ export interface Settings {
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
*/
enablePublicAccess?: boolean;
+ /**
+ * The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.
+ */
+ experiment_repoDrivenPermissionSyncIntervalMs?: number;
+ /**
+ * The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.
+ */
+ experiment_userDrivenPermissionSyncIntervalMs?: number;
}
/**
* Search context
@@ -98,11 +124,19 @@ export interface SearchContext {
/**
* List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
*/
- include: string[];
+ include?: string[];
+ /**
+ * List of connections to include in the search context.
+ */
+ includeConnections?: string[];
/**
* List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
*/
exclude?: string[];
+ /**
+ * List of connections to exclude from the search context.
+ */
+ excludeConnections?: string[];
/**
* Optional description of the search context that surfaces in the UI.
*/
@@ -253,6 +287,10 @@ export interface GitlabConnectionConfig {
* Exclude archived projects from syncing.
*/
archived?: boolean;
+ /**
+ * Exclude user-owned projects from syncing.
+ */
+ userOwnedProjects?: boolean;
/**
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
*/
@@ -344,6 +382,7 @@ export interface GerritConnectionConfig {
*/
hidden?: boolean;
};
+ revisions?: GitRevisions;
}
export interface BitbucketConnectionConfig {
/**
@@ -406,6 +445,80 @@ export interface BitbucketConnectionConfig {
};
revisions?: GitRevisions;
}
+export interface AzureDevOpsConnectionConfig {
+ /**
+ * Azure DevOps Configuration
+ */
+ type: "azuredevops";
+ /**
+ * A Personal Access Token (PAT).
+ */
+ token:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.
+ */
+ url?: string;
+ /**
+ * The type of Azure DevOps deployment
+ */
+ deploymentType: "cloud" | "server";
+ /**
+ * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
+ */
+ useTfsPath?: boolean;
+ /**
+ * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property.
+ */
+ orgs?: string[];
+ /**
+ * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server.
+ */
+ projects?: string[];
+ /**
+ * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'.
+ */
+ repos?: string[];
+ exclude?: {
+ /**
+ * Exclude disabled repositories from syncing.
+ */
+ disabled?: boolean;
+ /**
+ * List of repositories to exclude from syncing. Glob patterns are supported.
+ */
+ repos?: string[];
+ /**
+ * List of projects to exclude from syncing. Glob patterns are supported.
+ */
+ projects?: string[];
+ /**
+ * Exclude repositories based on their size.
+ */
+ size?: {
+ /**
+ * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing.
+ */
+ min?: number;
+ /**
+ * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing.
+ */
+ max?: number;
+ };
+ };
+ revisions?: GitRevisions;
+}
export interface GenericGitHostConnectionConfig {
/**
* Generic Git host configuration
@@ -417,3 +530,538 @@ export interface GenericGitHostConnectionConfig {
url: string;
revisions?: GitRevisions;
}
+export interface AmazonBedrockLanguageModel {
+ /**
+ * Amazon Bedrock Configuration
+ */
+ provider: "amazon-bedrock";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.
+ */
+ accessKeyId?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.
+ */
+ accessKeySecret?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.
+ */
+ sessionToken?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * The AWS region. Defaults to the `AWS_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+/**
+ * Optional headers to use with the model.
+ */
+export interface LanguageModelHeaders {
+ /**
+ * This interface was referenced by `LanguageModelHeaders`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]:
+ | string
+ | (
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ }
+ );
+}
+export interface AnthropicLanguageModel {
+ /**
+ * Anthropic Configuration
+ */
+ provider: "anthropic";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface AzureLanguageModel {
+ /**
+ * Azure Configuration
+ */
+ provider: "azure";
+ /**
+ * The deployment name of the Azure model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable.
+ */
+ resourceName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Sets a custom api version. Defaults to `preview`.
+ */
+ apiVersion?: string;
+ /**
+ * Use a different URL prefix for API calls. Either this or `resourceName` can be used.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface DeepSeekLanguageModel {
+ /**
+ * DeepSeek Configuration
+ */
+ provider: "deepseek";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleGenerativeAILanguageModel {
+ /**
+ * Google Generative AI Configuration
+ */
+ provider: "google-generative-ai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleVertexAnthropicLanguageModel {
+ /**
+ * Google Vertex AI Anthropic Configuration
+ */
+ provider: "google-vertex-anthropic";
+ /**
+ * The name of the Anthropic language model running on Google Vertex.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable.
+ */
+ project?: string;
+ /**
+ * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.
+ */
+ credentials?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleVertexLanguageModel {
+ /**
+ * Google Vertex AI Configuration
+ */
+ provider: "google-vertex";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable.
+ */
+ project?: string;
+ /**
+ * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.
+ */
+ credentials?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface MistralLanguageModel {
+ /**
+ * Mistral AI Configuration
+ */
+ provider: "mistral";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface OpenAILanguageModel {
+ /**
+ * OpenAI Configuration
+ */
+ provider: "openai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ /**
+ * The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings
+ */
+ reasoningEffort?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface OpenAICompatibleLanguageModel {
+ /**
+ * OpenAI Compatible Configuration
+ */
+ provider: "openai-compatible";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer .
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Base URL of the OpenAI-compatible chat completions API endpoint.
+ */
+ baseUrl: string;
+ headers?: LanguageModelHeaders;
+ queryParams?: LanguageModelQueryParams;
+}
+/**
+ * Optional query parameters to include in the request url.
+ */
+export interface LanguageModelQueryParams {
+ /**
+ * This interface was referenced by `LanguageModelQueryParams`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]:
+ | string
+ | (
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ }
+ );
+}
+export interface OpenRouterLanguageModel {
+ /**
+ * OpenRouter Configuration
+ */
+ provider: "openrouter";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface XaiLanguageModel {
+ /**
+ * xAI Configuration
+ */
+ provider: "xai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
diff --git a/packages/schemas/src/v3/languageModel.schema.ts b/packages/schemas/src/v3/languageModel.schema.ts
new file mode 100644
index 00000000..cd879e59
--- /dev/null
+++ b/packages/schemas/src/v3/languageModel.schema.ts
@@ -0,0 +1,2844 @@
+// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
+const schema = {
+ "type": "object",
+ "title": "LanguageModel",
+ "definitions": {
+ "AmazonBedrockLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "AzureLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "DeepSeekLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleGenerativeAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexAnthropicLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "GoogleVertexLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "MistralLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAILanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "OpenAICompatibleLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ "OpenRouterLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ "XaiLanguageModel": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "amazon-bedrock",
+ "description": "Amazon Bedrock Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "accessKeyId": {
+ "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "accessKeySecret": {
+ "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "sessionToken": {
+ "description": "Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.",
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "region": {
+ "type": "string",
+ "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.",
+ "examples": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ]
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "anthropic",
+ "description": "Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "azure",
+ "description": "Azure Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The deployment name of the Azure model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "resourceName": {
+ "type": "string",
+ "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable."
+ },
+ "apiVersion": {
+ "type": "string",
+ "description": "Sets a custom api version. Defaults to `preview`."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "deepseek",
+ "description": "DeepSeek Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-generative-ai",
+ "description": "Google Generative AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex-anthropic",
+ "description": "Google Vertex AI Anthropic Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the Anthropic language model running on Google Vertex.",
+ "examples": [
+ "claude-sonnet-4"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "google-vertex",
+ "description": "Google Vertex AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gemini-2.0-flash-exp",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "project": {
+ "type": "string",
+ "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable."
+ },
+ "region": {
+ "type": "string",
+ "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.",
+ "examples": [
+ "us-central1",
+ "us-east1",
+ "europe-west1"
+ ]
+ },
+ "credentials": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "mistral",
+ "description": "Mistral AI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai",
+ "description": "OpenAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "gpt-4.1",
+ "o4-mini",
+ "o3",
+ "o3-deep-research"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "description": "The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings",
+ "examples": [
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openai-compatible",
+ "description": "OpenAI Compatible Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer ."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Base URL of the OpenAI-compatible chat completions API endpoint.",
+ "examples": [
+ "http://localhost:8080/v1"
+ ]
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "queryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "baseUrl"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "openrouter",
+ "description": "OpenRouter Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model."
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "const": "xai",
+ "description": "xAI Configuration"
+ },
+ "model": {
+ "type": "string",
+ "description": "The name of the language model.",
+ "examples": [
+ "grok-beta",
+ "grok-vision-beta"
+ ]
+ },
+ "displayName": {
+ "type": "string",
+ "description": "Optional display name."
+ },
+ "token": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable."
+ },
+ "baseUrl": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
+ "description": "Optional base URL."
+ },
+ "headers": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "provider",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ ]
+} as const;
+export { schema as languageModelSchema };
\ No newline at end of file
diff --git a/packages/schemas/src/v3/languageModel.type.ts b/packages/schemas/src/v3/languageModel.type.ts
new file mode 100644
index 00000000..803a6c17
--- /dev/null
+++ b/packages/schemas/src/v3/languageModel.type.ts
@@ -0,0 +1,551 @@
+// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
+
+export type LanguageModel =
+ | AmazonBedrockLanguageModel
+ | AnthropicLanguageModel
+ | AzureLanguageModel
+ | DeepSeekLanguageModel
+ | GoogleGenerativeAILanguageModel
+ | GoogleVertexAnthropicLanguageModel
+ | GoogleVertexLanguageModel
+ | MistralLanguageModel
+ | OpenAILanguageModel
+ | OpenAICompatibleLanguageModel
+ | OpenRouterLanguageModel
+ | XaiLanguageModel;
+
+export interface AmazonBedrockLanguageModel {
+ /**
+ * Amazon Bedrock Configuration
+ */
+ provider: "amazon-bedrock";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.
+ */
+ accessKeyId?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.
+ */
+ accessKeySecret?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional session token to use with the model. Defaults to the `AWS_SESSION_TOKEN` environment variable.
+ */
+ sessionToken?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * The AWS region. Defaults to the `AWS_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+/**
+ * Optional headers to use with the model.
+ */
+export interface LanguageModelHeaders {
+ /**
+ * This interface was referenced by `LanguageModelHeaders`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]:
+ | string
+ | (
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ }
+ );
+}
+export interface AnthropicLanguageModel {
+ /**
+ * Anthropic Configuration
+ */
+ provider: "anthropic";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface AzureLanguageModel {
+ /**
+ * Azure Configuration
+ */
+ provider: "azure";
+ /**
+ * The deployment name of the Azure model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable.
+ */
+ resourceName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Sets a custom api version. Defaults to `preview`.
+ */
+ apiVersion?: string;
+ /**
+ * Use a different URL prefix for API calls. Either this or `resourceName` can be used.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface DeepSeekLanguageModel {
+ /**
+ * DeepSeek Configuration
+ */
+ provider: "deepseek";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleGenerativeAILanguageModel {
+ /**
+ * Google Generative AI Configuration
+ */
+ provider: "google-generative-ai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleVertexAnthropicLanguageModel {
+ /**
+ * Google Vertex AI Anthropic Configuration
+ */
+ provider: "google-vertex-anthropic";
+ /**
+ * The name of the Anthropic language model running on Google Vertex.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable.
+ */
+ project?: string;
+ /**
+ * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.
+ */
+ credentials?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface GoogleVertexLanguageModel {
+ /**
+ * Google Vertex AI Configuration
+ */
+ provider: "google-vertex";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable.
+ */
+ project?: string;
+ /**
+ * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.
+ */
+ region?: string;
+ /**
+ * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.
+ */
+ credentials?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface MistralLanguageModel {
+ /**
+ * Mistral AI Configuration
+ */
+ provider: "mistral";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface OpenAILanguageModel {
+ /**
+ * OpenAI Configuration
+ */
+ provider: "openai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ /**
+ * The reasoning effort to use with the model. Defaults to `medium`. See https://platform.openai.com/docs/guides/reasoning#get-started-with-reasonings
+ */
+ reasoningEffort?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface OpenAICompatibleLanguageModel {
+ /**
+ * OpenAI Compatible Configuration
+ */
+ provider: "openai-compatible";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key. If specified, adds an `Authorization` header to request headers with the value Bearer .
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Base URL of the OpenAI-compatible chat completions API endpoint.
+ */
+ baseUrl: string;
+ headers?: LanguageModelHeaders;
+ queryParams?: LanguageModelQueryParams;
+}
+/**
+ * Optional query parameters to include in the request url.
+ */
+export interface LanguageModelQueryParams {
+ /**
+ * This interface was referenced by `LanguageModelQueryParams`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]:
+ | string
+ | (
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ }
+ );
+}
+export interface OpenRouterLanguageModel {
+ /**
+ * OpenRouter Configuration
+ */
+ provider: "openrouter";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
+export interface XaiLanguageModel {
+ /**
+ * xAI Configuration
+ */
+ provider: "xai";
+ /**
+ * The name of the language model.
+ */
+ model: string;
+ /**
+ * Optional display name.
+ */
+ displayName?: string;
+ /**
+ * Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.
+ */
+ token?:
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ /**
+ * Optional base URL.
+ */
+ baseUrl?: string;
+ headers?: LanguageModelHeaders;
+}
diff --git a/packages/schemas/src/v3/searchContext.schema.ts b/packages/schemas/src/v3/searchContext.schema.ts
index c36cb801..7d97b94f 100644
--- a/packages/schemas/src/v3/searchContext.schema.ts
+++ b/packages/schemas/src/v3/searchContext.schema.ts
@@ -18,6 +18,13 @@ const schema = {
]
]
},
+ "includeConnections": {
+ "type": "array",
+ "description": "List of connections to include in the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"exclude": {
"type": "array",
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
@@ -31,14 +38,18 @@ const schema = {
]
]
},
+ "excludeConnections": {
+ "type": "array",
+ "description": "List of connections to exclude from the search context.",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string",
"description": "Optional description of the search context that surfaces in the UI."
}
},
- "required": [
- "include"
- ],
"additionalProperties": false
} as const;
export { schema as searchContextSchema };
\ No newline at end of file
diff --git a/packages/schemas/src/v3/searchContext.type.ts b/packages/schemas/src/v3/searchContext.type.ts
index 255b5a03..ca2cb1b2 100644
--- a/packages/schemas/src/v3/searchContext.type.ts
+++ b/packages/schemas/src/v3/searchContext.type.ts
@@ -7,11 +7,19 @@ export interface SearchContext {
/**
* List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
*/
- include: string[];
+ include?: string[];
+ /**
+ * List of connections to include in the search context.
+ */
+ includeConnections?: string[];
/**
* List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
*/
exclude?: string[];
+ /**
+ * List of connections to exclude from the search context.
+ */
+ excludeConnections?: string[];
/**
* Optional description of the search context that surfaces in the UI.
*/
diff --git a/packages/schemas/src/v3/shared.schema.ts b/packages/schemas/src/v3/shared.schema.ts
index 0c1792ae..5ecacd44 100644
--- a/packages/schemas/src/v3/shared.schema.ts
+++ b/packages/schemas/src/v3/shared.schema.ts
@@ -73,6 +73,94 @@ const schema = {
}
},
"additionalProperties": false
+ },
+ "LanguageModelHeaders": {
+ "type": "object",
+ "description": "Optional headers to use with the model.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "LanguageModelQueryParams": {
+ "type": "object",
+ "description": "Optional query parameters to include in the request url.",
+ "patternProperties": {
+ "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
}
}
} as const;
diff --git a/packages/schemas/src/v3/shared.type.ts b/packages/schemas/src/v3/shared.type.ts
index eeec734c..727de2be 100644
--- a/packages/schemas/src/v3/shared.type.ts
+++ b/packages/schemas/src/v3/shared.type.ts
@@ -37,3 +37,29 @@ export interface GitRevisions {
*/
tags?: string[];
}
+/**
+ * Optional headers to use with the model.
+ *
+ * This interface was referenced by `Shared`'s JSON-Schema
+ * via the `definition` "LanguageModelHeaders".
+ */
+export interface LanguageModelHeaders {
+ /**
+ * This interface was referenced by `LanguageModelHeaders`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]: string | Token;
+}
+/**
+ * Optional query parameters to include in the request url.
+ *
+ * This interface was referenced by `Shared`'s JSON-Schema
+ * via the `definition` "LanguageModelQueryParams".
+ */
+export interface LanguageModelQueryParams {
+ /**
+ * This interface was referenced by `LanguageModelQueryParams`'s JSON-Schema definition
+ * via the `patternProperty` "^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$".
+ */
+ [k: string]: string | Token;
+}
diff --git a/packages/shared/src/ee/syncSearchContexts.ts b/packages/shared/src/ee/syncSearchContexts.ts
index 7ab59b35..8dd932ae 100644
--- a/packages/shared/src/ee/syncSearchContexts.ts
+++ b/packages/shared/src/ee/syncSearchContexts.ts
@@ -36,9 +36,39 @@ export const syncSearchContexts = async (params: SyncSearchContextsParams) => {
}
});
- let newReposInContext = allRepos.filter(repo => {
- return micromatch.isMatch(repo.name, newContextConfig.include);
- });
+ let newReposInContext: { id: number, name: string }[] = [];
+ if(newContextConfig.include) {
+ newReposInContext = allRepos.filter(repo => {
+ return micromatch.isMatch(repo.name, newContextConfig.include!);
+ });
+ }
+
+ if(newContextConfig.includeConnections) {
+ const connections = await db.connection.findMany({
+ where: {
+ orgId,
+ name: {
+ in: newContextConfig.includeConnections,
+ }
+ },
+ include: {
+ repos: {
+ select: {
+ repo: {
+ select: {
+ id: true,
+ name: true,
+ }
+ }
+ }
+ }
+ }
+ });
+
+ for (const connection of connections) {
+ newReposInContext = newReposInContext.concat(connection.repos.map(repo => repo.repo));
+ }
+ }
if (newContextConfig.exclude) {
const exclude = newContextConfig.exclude;
@@ -47,6 +77,35 @@ export const syncSearchContexts = async (params: SyncSearchContextsParams) => {
});
}
+ if (newContextConfig.excludeConnections) {
+ const connections = await db.connection.findMany({
+ where: {
+ orgId,
+ name: {
+ in: newContextConfig.excludeConnections,
+ }
+ },
+ include: {
+ repos: {
+ select: {
+ repo: {
+ select: {
+ id: true,
+ name: true,
+ }
+ }
+ }
+ }
+ }
+ });
+
+ for (const connection of connections) {
+ newReposInContext = newReposInContext.filter(repo => {
+ return !connection.repos.map(r => r.repo.id).includes(repo.id);
+ });
+ }
+ }
+
const currentReposInContext = (await db.searchContext.findUnique({
where: {
name_orgId: {
diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts
index 965989c1..be40b927 100644
--- a/packages/shared/src/entitlements.ts
+++ b/packages/shared/src/entitlements.ts
@@ -38,15 +38,16 @@ const entitlements = [
"sso",
"code-nav",
"audit",
- "analytics"
+ "analytics",
+ "permission-syncing"
] as const;
export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record = {
oss: ["anonymous-access"],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
- "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
- "self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"],
+ "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics", "permission-syncing"],
+ "self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics", "permission-syncing"],
// Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
} as const;
diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts
index 3cc4be65..ce00642e 100644
--- a/packages/shared/src/index.server.ts
+++ b/packages/shared/src/index.server.ts
@@ -12,6 +12,7 @@ export type {
export {
base64Decode,
loadConfig,
+ loadJsonFile,
isRemotePath,
} from "./utils.js";
export {
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
index 94f66324..69cbc52e 100644
--- a/packages/shared/src/utils.ts
+++ b/packages/shared/src/utils.ts
@@ -3,6 +3,7 @@ import { indexSchema } from "@sourcebot/schemas/v3/index.schema";
import { readFile } from 'fs/promises';
import stripJsonComments from 'strip-json-comments';
import { Ajv } from "ajv";
+import { z } from "zod";
const ajv = new Ajv({
validateFormats: false,
@@ -18,6 +19,66 @@ export const isRemotePath = (path: string) => {
return path.startsWith('https://') || path.startsWith('http://');
}
+// TODO: Merge this with config loading logic which uses AJV
+export const loadJsonFile = async (
+ filePath: string,
+ schema: any
+): Promise => {
+ const fileContent = await (async () => {
+ if (isRemotePath(filePath)) {
+ const response = await fetch(filePath);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch file ${filePath}: ${response.statusText}`);
+ }
+ return response.text();
+ } else {
+ // Retry logic for handling race conditions with mounted volumes
+ const maxAttempts = 5;
+ const retryDelayMs = 2000;
+ let lastError: Error | null = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ return await readFile(filePath, {
+ encoding: 'utf-8',
+ });
+ } catch (error) {
+ lastError = error as Error;
+
+ // Only retry on ENOENT errors (file not found)
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
+ throw error; // Throw immediately for non-ENOENT errors
+ }
+
+ // Log warning before retry (except on the last attempt)
+ if (attempt < maxAttempts) {
+ console.warn(`File not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`);
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
+ }
+ }
+ }
+
+ // If we've exhausted all retries, throw the last ENOENT error
+ if (lastError) {
+ throw lastError;
+ }
+
+ throw new Error('Failed to load file after all retry attempts');
+ }
+ })();
+
+ const parsedData = JSON.parse(stripJsonComments(fileContent));
+
+ try {
+ return schema.parse(parsedData);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ throw new Error(`File '${filePath}' is invalid: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);
+ }
+ throw error;
+ }
+}
+
export const loadConfig = async (configPath: string): Promise => {
const configContent = await (async () => {
if (isRemotePath(configPath)) {
@@ -27,9 +88,38 @@ export const loadConfig = async (configPath: string): Promise =
}
return response.text();
} else {
- return readFile(configPath, {
- encoding: 'utf-8',
- });
+ // Retry logic for handling race conditions with mounted volumes
+ const maxAttempts = 5;
+ const retryDelayMs = 2000;
+ let lastError: Error | null = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ return await readFile(configPath, {
+ encoding: 'utf-8',
+ });
+ } catch (error) {
+ lastError = error as Error;
+
+ // Only retry on ENOENT errors (file not found)
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
+ throw error; // Throw immediately for non-ENOENT errors
+ }
+
+ // Log warning before retry (except on the last attempt)
+ if (attempt < maxAttempts) {
+ console.warn(`Config file not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`);
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
+ }
+ }
+ }
+
+ // If we've exhausted all retries, throw the last ENOENT error
+ if (lastError) {
+ throw lastError;
+ }
+
+ throw new Error('Failed to load config after all retry attempts');
}
})();
diff --git a/packages/web/.eslintignore b/packages/web/.eslintignore
index 68469290..b1a30067 100644
--- a/packages/web/.eslintignore
+++ b/packages/web/.eslintignore
@@ -1,2 +1,3 @@
# shadcn components
src/components/
+next-env.d.ts
\ No newline at end of file
diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs
index de511a92..2ab0b642 100644
--- a/packages/web/next.config.mjs
+++ b/packages/web/next.config.mjs
@@ -38,6 +38,8 @@ const nextConfig = {
},
]
},
+
+ turbopack: {}
};
export default withSentryConfig(nextConfig, {
diff --git a/packages/web/package.json b/packages/web/package.json
index 62ac9690..709d0e47 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -3,16 +3,28 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev",
- "build": "next build",
+ "dev": "next dev --turbopack",
+ "build": "cross-env SKIP_ENV_VALIDATION=1 next build",
"start": "next start",
- "lint": "cross-env SKIP_ENV_VALIDATION=1 next lint",
- "test": "vitest",
+ "lint": "cross-env SKIP_ENV_VALIDATION=1 eslint .",
+ "test": "cross-env SKIP_ENV_VALIDATION=1 vitest",
"dev:emails": "email dev --dir ./src/emails",
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
},
"dependencies": {
+ "@ai-sdk/amazon-bedrock": "^3.0.22",
+ "@ai-sdk/anthropic": "^2.0.17",
+ "@ai-sdk/azure": "^2.0.32",
+ "@ai-sdk/deepseek": "^1.0.18",
+ "@ai-sdk/google": "^2.0.14",
+ "@ai-sdk/google-vertex": "^3.0.27",
+ "@ai-sdk/mistral": "^2.0.14",
+ "@ai-sdk/openai": "^2.0.32",
+ "@ai-sdk/openai-compatible": "^1.0.18",
+ "@ai-sdk/react": "^2.0.45",
+ "@ai-sdk/xai": "^2.0.20",
"@auth/prisma-adapter": "^2.7.4",
+ "@aws-sdk/credential-providers": "^3.890.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-css": "^6.3.0",
@@ -43,9 +55,15 @@
"@hookform/resolvers": "^3.9.0",
"@iconify/react": "^5.1.0",
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
+ "@openrouter/ai-sdk-provider": "^1.1.0",
+ "@opentelemetry/api-logs": "^0.203.0",
+ "@opentelemetry/instrumentation": "^0.203.0",
+ "@opentelemetry/sdk-logs": "^0.203.0",
+ "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.6",
@@ -82,16 +100,20 @@
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.6.0",
"@t3-oss/env-nextjs": "^0.12.0",
+ "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.53.3",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.8",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/codemirror-themes": "^4.23.6",
"@uiw/react-codemirror": "^4.23.0",
+ "@vercel/otel": "^1.13.0",
"@viz-js/lang-dot": "^1.0.4",
"@xiechao/codemirror-lang-handlebars": "^1.0.4",
+ "ai": "^5.0.45",
"ajv": "^8.17.1",
"bcryptjs": "^3.0.2",
+ "chokidar": "^4.0.3",
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
@@ -117,14 +139,17 @@
"embla-carousel-react": "^8.3.0",
"escape-string-regexp": "^5.0.0",
"fuse.js": "^7.0.0",
- "google-auth-library": "^9.15.1",
+ "google-auth-library": "^10.1.0",
"graphql": "^16.9.0",
"http-status-codes": "^2.3.0",
"input-otp": "^1.4.2",
+ "langfuse": "^3.38.4",
+ "langfuse-vercel": "^3.38.4",
"lucide-react": "^0.517.0",
"micromatch": "^4.0.8",
- "next": "14.2.26",
+ "next": "15.5.0",
"next-auth": "^5.0.0-beta.25",
+ "next-navigation-guard": "^0.2.0",
"next-themes": "^0.3.0",
"nodemailer": "^6.10.0",
"octokit": "^4.1.3",
@@ -133,42 +158,53 @@
"posthog-js": "^1.161.5",
"pretty-bytes": "^6.1.1",
"psl": "^1.15.0",
- "react": "^18",
+ "react": "19.1.1",
"react-device-detect": "^2.2.3",
- "react-dom": "^18",
+ "react-dom": "19.1.1",
"react-hook-form": "^7.53.0",
"react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0",
+ "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.1",
"recharts": "^2.15.3",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
"scroll-into-view-if-needed": "^3.1.0",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"simple-git": "^3.27.0",
+ "slate": "^0.117.0",
+ "slate-dom": "^0.116.0",
+ "slate-history": "^0.113.1",
+ "slate-react": "^0.117.1",
"strip-json-comments": "^5.0.1",
"stripe": "^17.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"vscode-icons-js": "^11.6.1",
- "zod": "^3.24.3",
+ "zod": "^3.25.74",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
+ "@eslint/eslintrc": "^3",
"@tanstack/eslint-plugin-query": "^5.74.7",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.0",
"@types/micromatch": "^4.0.9",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/psl": "^1.1.3",
- "@types/react": "^18",
- "@types/react-dom": "^18",
- "@typescript-eslint/eslint-plugin": "^8.3.0",
- "@typescript-eslint/parser": "^8.3.0",
+ "@types/react": "19.1.10",
+ "@types/react-dom": "19.1.7",
+ "@typescript-eslint/eslint-plugin": "^8.40.0",
+ "@typescript-eslint/parser": "^8.40.0",
"cross-env": "^7.0.3",
"eslint": "^8",
- "eslint-config-next": "14.2.6",
- "eslint-plugin-react": "^7.35.0",
- "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-config-next": "15.5.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^25.0.1",
"npm-run-all": "^4.1.5",
"postcss": "^8",
@@ -177,6 +213,11 @@
"tsx": "^4.19.2",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.3",
- "vitest": "^2.1.5"
+ "vitest": "^2.1.5",
+ "vitest-mock-extended": "^3.1.0"
+ },
+ "resolutions": {
+ "@types/react": "19.1.10",
+ "@types/react-dom": "19.1.7"
}
}
diff --git a/packages/web/public/anthropic.svg b/packages/web/public/anthropic.svg
new file mode 100644
index 00000000..578301de
--- /dev/null
+++ b/packages/web/public/anthropic.svg
@@ -0,0 +1,7 @@
+
+
+ Anthropic Icon Streamline Icon: https://streamlinehq.com
+
+
+
+
\ No newline at end of file
diff --git a/packages/web/public/ask_sb_tutorial_at_mentions.png b/packages/web/public/ask_sb_tutorial_at_mentions.png
new file mode 100644
index 00000000..412b02fe
Binary files /dev/null and b/packages/web/public/ask_sb_tutorial_at_mentions.png differ
diff --git a/packages/web/public/ask_sb_tutorial_citations.png b/packages/web/public/ask_sb_tutorial_citations.png
new file mode 100644
index 00000000..2266fdb5
Binary files /dev/null and b/packages/web/public/ask_sb_tutorial_citations.png differ
diff --git a/packages/web/public/ask_sb_tutorial_search_scope.png b/packages/web/public/ask_sb_tutorial_search_scope.png
new file mode 100644
index 00000000..33376ca8
Binary files /dev/null and b/packages/web/public/ask_sb_tutorial_search_scope.png differ
diff --git a/packages/web/public/azureai.svg b/packages/web/public/azureai.svg
new file mode 100644
index 00000000..c4ed5c4d
--- /dev/null
+++ b/packages/web/public/azureai.svg
@@ -0,0 +1 @@
+AzureAI
\ No newline at end of file
diff --git a/packages/web/public/azuredevops.svg b/packages/web/public/azuredevops.svg
new file mode 100644
index 00000000..3d4a462f
--- /dev/null
+++ b/packages/web/public/azuredevops.svg
@@ -0,0 +1 @@
+Icon-devops-261
\ No newline at end of file
diff --git a/packages/web/public/bedrock.svg b/packages/web/public/bedrock.svg
new file mode 100644
index 00000000..a5649399
--- /dev/null
+++ b/packages/web/public/bedrock.svg
@@ -0,0 +1 @@
+Bedrock
\ No newline at end of file
diff --git a/packages/web/public/deepseek.svg b/packages/web/public/deepseek.svg
new file mode 100644
index 00000000..3fc23024
--- /dev/null
+++ b/packages/web/public/deepseek.svg
@@ -0,0 +1 @@
+DeepSeek
\ No newline at end of file
diff --git a/packages/web/public/gemini.svg b/packages/web/public/gemini.svg
new file mode 100644
index 00000000..3d7ccb42
--- /dev/null
+++ b/packages/web/public/gemini.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/web/public/mistral.svg b/packages/web/public/mistral.svg
new file mode 100644
index 00000000..8e03e244
--- /dev/null
+++ b/packages/web/public/mistral.svg
@@ -0,0 +1 @@
+Mistral
\ No newline at end of file
diff --git a/packages/web/public/openai.svg b/packages/web/public/openai.svg
new file mode 100644
index 00000000..3b4eff96
--- /dev/null
+++ b/packages/web/public/openai.svg
@@ -0,0 +1,2 @@
+
+OpenAI icon
\ No newline at end of file
diff --git a/packages/web/public/openrouter.svg b/packages/web/public/openrouter.svg
new file mode 100644
index 00000000..e6cca2a8
--- /dev/null
+++ b/packages/web/public/openrouter.svg
@@ -0,0 +1 @@
+OpenRouter
\ No newline at end of file
diff --git a/packages/web/public/xai.svg b/packages/web/public/xai.svg
new file mode 100644
index 00000000..536e7139
--- /dev/null
+++ b/packages/web/public/xai.svg
@@ -0,0 +1 @@
+Grok
\ No newline at end of file
diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts
new file mode 100644
index 00000000..4db4de46
--- /dev/null
+++ b/packages/web/src/__mocks__/prisma.ts
@@ -0,0 +1,50 @@
+import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
+import { ApiKey, Org, PrismaClient, User } from '@prisma/client';
+import { beforeEach, vi } from 'vitest';
+import { mockDeep, mockReset } from 'vitest-mock-extended';
+
+beforeEach(() => {
+ mockReset(prisma);
+});
+
+export const prisma = mockDeep();
+
+export const MOCK_ORG: Org = {
+ id: SINGLE_TENANT_ORG_ID,
+ name: SINGLE_TENANT_ORG_NAME,
+ domain: SINGLE_TENANT_ORG_DOMAIN,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ isOnboarded: true,
+ imageUrl: null,
+ metadata: null,
+ memberApprovalRequired: false,
+ stripeCustomerId: null,
+ stripeSubscriptionStatus: null,
+ stripeLastUpdatedAt: null,
+ inviteLinkEnabled: false,
+ inviteLinkId: null
+}
+
+export const MOCK_API_KEY: ApiKey = {
+ name: 'Test API Key',
+ hash: 'apikey',
+ createdAt: new Date(),
+ lastUsedAt: new Date(),
+ orgId: 1,
+ createdById: '1',
+}
+
+export const MOCK_USER: User = {
+ id: '1',
+ name: 'Test User',
+ email: 'test@test.com',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ hashedPassword: null,
+ emailVerified: null,
+ image: null,
+ permissionSyncedAt: null
+}
+
+export const userScopedPrismaClientExtension = vi.fn();
\ No newline at end of file
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index ed73950a..5b73922c 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -1,44 +1,46 @@
'use server';
+import { getAuditService } from "@/ee/features/audit/factory";
import { env } from "@/env.mjs";
+import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
-import { CodeHostType, isServiceError } from "@/lib/utils";
+import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma";
import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs';
-import { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto";
-import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db";
+import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
+import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
+import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
+import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
-import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
-import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
-import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
-import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
+import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
+import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
+import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
+import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
+import { getPlan, hasEntitlement } from "@sourcebot/shared";
import Ajv from "ajv";
import { StatusCodes } from "http-status-codes";
import { cookies, headers } from "next/headers";
import { createTransport } from "nodemailer";
+import { Octokit } from "octokit";
import { auth } from "./auth";
import { getConnection } from "./data/connection";
+import { getOrgFromDomain } from "./data/org";
+import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
-import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
-import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
-import { TenancyMode, ApiKeyPayload } from "./lib/types";
-import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
-import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
-import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
-import { getPlan, hasEntitlement } from "@sourcebot/shared";
-import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
-import { createLogger } from "@sourcebot/logger";
-import { getAuditService } from "@/ee/features/audit/factory";
-import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
-import { getOrgMetadata } from "@/lib/utils";
-import { getOrgFromDomain } from "./data/org";
+import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
+import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
+import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
+import { ApiKeyPayload, TenancyMode } from "./lib/types";
+import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
const ajv = new Ajv({
validateFormats: false,
@@ -59,6 +61,11 @@ export const sew = async (fn: () => Promise): Promise =>
} catch (e) {
Sentry.captureException(e);
logger.error(e);
+
+ if (e instanceof Error) {
+ return unexpectedError(e.message);
+ }
+
return unexpectedError(`An unexpected error occurred. Please try again later.`);
}
}
@@ -180,7 +187,7 @@ export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () =>
////// Actions ///////
-export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
+export const createOrg = async (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withTenancyModeEnforcement('multi', () =>
withAuth(async (userId) => {
const org = await prisma.org.create({
@@ -287,7 +294,7 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo
})
));
-export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => sew(() =>
+export const getSecrets = async (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const secrets = await prisma.secret.findMany({
@@ -631,111 +638,96 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
}
})));
-export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
- withAuth((userId) =>
- withOrgMembership(userId, domain, async ({ org }) => {
- const repos = await prisma.repo.findMany({
- where: {
- orgId: org.id,
- ...(filter.status ? {
- repoIndexingStatus: { in: filter.status }
- } : {}),
- ...(filter.connectionId ? {
- connections: {
- some: {
- connectionId: filter.connectionId
- }
- }
- } : {}),
- },
- include: {
+export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ const repos = await prisma.repo.findMany({
+ where: {
+ orgId: org.id,
+ ...(filter.status ? {
+ repoIndexingStatus: { in: filter.status }
+ } : {}),
+ ...(filter.connectionId ? {
connections: {
- include: {
- connection: true,
+ some: {
+ connectionId: filter.connectionId
}
}
- }
- });
-
- return repos.map((repo) => repositoryQuerySchema.parse({
- codeHostType: repo.external_codeHostType,
- repoId: repo.id,
- repoName: repo.name,
- repoDisplayName: repo.displayName ?? undefined,
- repoCloneUrl: repo.cloneUrl,
- webUrl: repo.webUrl ?? undefined,
- linkedConnections: repo.connections.map(({ connection }) => ({
- id: connection.id,
- name: connection.name,
- })),
- imageUrl: repo.imageUrl ?? undefined,
- indexedAt: repo.indexedAt ?? undefined,
- repoIndexingStatus: repo.repoIndexingStatus,
- }));
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
- ));
-
-export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
- withAuth((userId) =>
- withOrgMembership(userId, domain, async ({ org }) => {
- // @note: repo names are represented by their remote url
- // on the code host. E.g.,:
- // - github.com/sourcebot-dev/sourcebot
- // - gitlab.com/gitlab-org/gitlab
- // - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
- // etc.
- //
- // For most purposes, repo names are unique within an org, so using
- // findFirst is equivalent to findUnique. Duplicates _can_ occur when
- // a repository is specified by its remote url in a generic `git`
- // connection. For example:
- //
- // ```json
- // {
- // "connections": {
- // "connection-1": {
- // "type": "github",
- // "repos": [
- // "sourcebot-dev/sourcebot"
- // ]
- // },
- // "connection-2": {
- // "type": "git",
- // "url": "file:///tmp/repos/sourcebot"
- // }
- // }
- // }
- // ```
- //
- // In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
- // We will leave this as an edge case for now since it's unlikely to happen in practice.
- //
- // @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
- // these cases.
- // @see: repoCompileUtils.ts
- const repo = await prisma.repo.findFirst({
- where: {
- name: repoName,
- orgId: org.id,
- },
- });
-
- if (!repo) {
- return notFound();
+ } : {}),
}
+ });
- return {
- id: repo.id,
- name: repo.name,
- displayName: repo.displayName ?? undefined,
- codeHostType: repo.external_codeHostType,
- webUrl: repo.webUrl ?? undefined,
- imageUrl: repo.imageUrl ?? undefined,
- indexedAt: repo.indexedAt ?? undefined,
- repoIndexingStatus: repo.repoIndexingStatus,
- }
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
- ));
+ return repos.map((repo) => repositoryQuerySchema.parse({
+ codeHostType: repo.external_codeHostType,
+ repoId: repo.id,
+ repoName: repo.name,
+ repoDisplayName: repo.displayName ?? undefined,
+ repoCloneUrl: repo.cloneUrl,
+ webUrl: repo.webUrl ?? undefined,
+ imageUrl: repo.imageUrl ?? undefined,
+ indexedAt: repo.indexedAt ?? undefined,
+ repoIndexingStatus: repo.repoIndexingStatus,
+ }))
+ }));
+
+export const getRepoInfoByName = async (repoName: string) => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ // @note: repo names are represented by their remote url
+ // on the code host. E.g.,:
+ // - github.com/sourcebot-dev/sourcebot
+ // - gitlab.com/gitlab-org/gitlab
+ // - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
+ // etc.
+ //
+ // For most purposes, repo names are unique within an org, so using
+ // findFirst is equivalent to findUnique. Duplicates _can_ occur when
+ // a repository is specified by its remote url in a generic `git`
+ // connection. For example:
+ //
+ // ```json
+ // {
+ // "connections": {
+ // "connection-1": {
+ // "type": "github",
+ // "repos": [
+ // "sourcebot-dev/sourcebot"
+ // ]
+ // },
+ // "connection-2": {
+ // "type": "git",
+ // "url": "file:///tmp/repos/sourcebot"
+ // }
+ // }
+ // }
+ // ```
+ //
+ // In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
+ // We will leave this as an edge case for now since it's unlikely to happen in practice.
+ //
+ // @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
+ // these cases.
+ // @see: repoCompileUtils.ts
+ const repo = await prisma.repo.findFirst({
+ where: {
+ name: repoName,
+ orgId: org.id,
+ },
+ });
+
+ if (!repo) {
+ return notFound();
+ }
+
+ return {
+ id: repo.id,
+ name: repo.name,
+ displayName: repo.displayName ?? undefined,
+ codeHostType: repo.external_codeHostType,
+ webUrl: repo.webUrl ?? undefined,
+ imageUrl: repo.imageUrl ?? undefined,
+ indexedAt: repo.indexedAt ?? undefined,
+ repoIndexingStatus: repo.repoIndexingStatus,
+ }
+ }));
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withAuth((userId) =>
@@ -785,6 +777,142 @@ export const createConnection = async (name: string, type: CodeHostType, connect
}, OrgRole.OWNER)
));
+export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "This feature is not enabled.",
+ } satisfies ServiceError;
+ }
+
+ // Parse repository URL to extract owner/repo
+ const repoInfo = (() => {
+ const url = repositoryUrl.trim();
+
+ // Handle various GitHub URL formats
+ const patterns = [
+ // https://github.com/owner/repo or https://github.com/owner/repo.git
+ /^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
+ // github.com/owner/repo
+ /^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
+ // owner/repo
+ /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
+ ];
+
+ for (const pattern of patterns) {
+ const match = url.match(pattern);
+ if (match) {
+ return {
+ owner: match[1],
+ repo: match[2]
+ };
+ }
+ }
+
+ return null;
+ })();
+
+ if (!repoInfo) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
+ } satisfies ServiceError;
+ }
+
+ const { owner, repo } = repoInfo;
+
+ // Use GitHub API to fetch repository information and get the external_id
+ const octokit = new Octokit({
+ auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
+ });
+
+ let githubRepo;
+ try {
+ const response = await octokit.rest.repos.get({
+ owner,
+ repo,
+ });
+ githubRepo = response.data;
+ } catch (error) {
+ if (isHttpError(error, 404)) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
+ } satisfies ServiceError;
+ }
+
+ if (isHttpError(error, 403)) {
+ return {
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
+ } satisfies ServiceError;
+ }
+
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ } satisfies ServiceError;
+ }
+
+ if (githubRepo.private) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Only public repositories can be added.",
+ } satisfies ServiceError;
+ }
+
+ // Check if this repository is already connected using the external_id
+ const existingRepo = await prisma.repo.findFirst({
+ where: {
+ orgId: org.id,
+ external_id: githubRepo.id.toString(),
+ external_codeHostType: 'github',
+ external_codeHostUrl: 'https://github.com',
+ }
+ });
+
+ if (existingRepo) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
+ message: "This repository already exists.",
+ } satisfies ServiceError;
+ }
+
+ const connectionName = `${owner}-${repo}-${Date.now()}`;
+
+ // Create GitHub connection config
+ const connectionConfig: GithubConnectionConfig = {
+ type: "github" as const,
+ repos: [`${owner}/${repo}`],
+ ...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? {
+ token: {
+ env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
+ }
+ } : {})
+ };
+
+ const connection = await prisma.connection.create({
+ data: {
+ orgId: org.id,
+ name: connectionName,
+ config: connectionConfig as unknown as Prisma.InputJsonValue,
+ connectionType: 'github',
+ }
+ });
+
+ return {
+ connectionId: connection.id,
+ }
+ }));
+
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
@@ -889,24 +1017,22 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
})
));
-export const flagReposForIndex = async (repoIds: number[], domain: string) => sew(() =>
- withAuth((userId) =>
- withOrgMembership(userId, domain, async ({ org }) => {
- await prisma.repo.updateMany({
- where: {
- id: { in: repoIds },
- orgId: org.id,
- },
- data: {
- repoIndexingStatus: RepoIndexingStatus.NEW,
- }
- });
-
- return {
- success: true,
+export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
+ withAuthV2(async ({ org, prisma }) => {
+ await prisma.repo.updateMany({
+ where: {
+ id: { in: repoIds },
+ orgId: org.id,
+ },
+ data: {
+ repoIndexingStatus: RepoIndexingStatus.NEW,
}
- })
- ));
+ });
+
+ return {
+ success: true,
+ }
+ }));
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
@@ -1846,7 +1972,7 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
));
export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => {
- await cookies().set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
+ await (await cookies()).set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
return true;
});
@@ -1857,84 +1983,87 @@ export const getSearchContexts = async (domain: string) => sew(() =>
where: {
orgId: org.id,
},
+ include: {
+ repos: true,
+ },
});
return searchContexts.map((context) => ({
+ id: context.id,
name: context.name,
description: context.description ?? undefined,
+ repoNames: context.repos.map((repo) => repo.name),
}));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));
-export const getRepoImage = async (repoId: number, domain: string): Promise => sew(async () => {
- return await withAuth(async (userId) => {
- return await withOrgMembership(userId, domain, async ({ org }) => {
- const repo = await prisma.repo.findUnique({
- where: {
- id: repoId,
- orgId: org.id,
- },
- include: {
- connections: {
- include: {
- connection: true,
- }
+export const getRepoImage = async (repoId: number): Promise => sew(async () => {
+ return await withOptionalAuthV2(async ({ org, prisma }) => {
+ const repo = await prisma.repo.findUnique({
+ where: {
+ id: repoId,
+ orgId: org.id,
+ },
+ include: {
+ connections: {
+ include: {
+ connection: true,
}
}
+ },
+ });
+
+ if (!repo || !repo.imageUrl) {
+ return notFound();
+ }
+
+ const authHeaders: Record = {};
+ for (const { connection } of repo.connections) {
+ try {
+ if (connection.connectionType === 'github') {
+ const config = connection.config as unknown as GithubConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
+ authHeaders['Authorization'] = `token ${token}`;
+ break;
+ }
+ } else if (connection.connectionType === 'gitlab') {
+ const config = connection.config as unknown as GitlabConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
+ authHeaders['PRIVATE-TOKEN'] = token;
+ break;
+ }
+ } else if (connection.connectionType === 'gitea') {
+ const config = connection.config as unknown as GiteaConnectionConfig;
+ if (config.token) {
+ const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
+ authHeaders['Authorization'] = `token ${token}`;
+ break;
+ }
+ }
+ } catch (error) {
+ logger.warn(`Failed to get token for connection ${connection.id}:`, error);
+ }
+ }
+
+ try {
+ const response = await fetch(repo.imageUrl, {
+ headers: authHeaders,
});
- if (!repo || !repo.imageUrl) {
+ if (!response.ok) {
+ logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
return notFound();
}
- const authHeaders: Record = {};
- for (const { connection } of repo.connections) {
- try {
- if (connection.connectionType === 'github') {
- const config = connection.config as unknown as GithubConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
- authHeaders['Authorization'] = `token ${token}`;
- break;
- }
- } else if (connection.connectionType === 'gitlab') {
- const config = connection.config as unknown as GitlabConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
- authHeaders['PRIVATE-TOKEN'] = token;
- break;
- }
- } else if (connection.connectionType === 'gitea') {
- const config = connection.config as unknown as GiteaConnectionConfig;
- if (config.token) {
- const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
- authHeaders['Authorization'] = `token ${token}`;
- break;
- }
- }
- } catch (error) {
- logger.warn(`Failed to get token for connection ${connection.id}:`, error);
- }
- }
-
- try {
- const response = await fetch(repo.imageUrl, {
- headers: authHeaders,
- });
-
- if (!response.ok) {
- logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
- return notFound();
- }
-
- const imageBuffer = await response.arrayBuffer();
- return imageBuffer;
- } catch (error) {
- logger.error(`Error proxying image for repo ${repoId}:`, error);
- return notFound();
- }
- }, /* minRequiredRole = */ OrgRole.GUEST);
- }, /* allowAnonymousAccess = */ true);
+ const imageBuffer = await response.arrayBuffer();
+ return imageBuffer;
+ } catch (error) {
+ logger.error(`Error proxying image for repo ${repoId}:`, error);
+ return notFound();
+ }
+ })
});
export const getAnonymousAccessStatus = async (domain: string): Promise => sew(async () => {
@@ -1998,6 +2127,20 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
});
});
+export async function setSearchModeCookie(searchMode: "precise" | "agentic") {
+ const cookieStore = await cookies();
+ cookieStore.set(SEARCH_MODE_COOKIE_NAME, searchMode, {
+ httpOnly: false, // Allow client-side access
+ });
+}
+
+export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean) {
+ const cookieStore = await cookies();
+ cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", {
+ httpOnly: false, // Allow client-side access
+ });
+}
+
////// Helpers ///////
const parseConnectionConfig = (config: string) => {
@@ -2025,6 +2168,8 @@ const parseConnectionConfig = (config: string) => {
return gerritSchema;
case 'bitbucket':
return bitbucketSchema;
+ case 'azuredevops':
+ return azuredevopsSchema;
case 'git':
return genericGitHostSchema;
}
@@ -2059,7 +2204,8 @@ const parseConnectionConfig = (config: string) => {
switch (connectionType) {
case "gitea":
case "github":
- case "bitbucket": {
+ case "bitbucket":
+ case "azuredevops": {
return {
numRepos: parsedConfig.repos?.length,
hasToken: !!parsedConfig.token,
diff --git a/packages/web/src/app/[domain]/agents/page.tsx b/packages/web/src/app/[domain]/agents/page.tsx
index 3bbccd0c..03b2a2d6 100644
--- a/packages/web/src/app/[domain]/agents/page.tsx
+++ b/packages/web/src/app/[domain]/agents/page.tsx
@@ -13,7 +13,13 @@ const agents = [
},
];
-export default function AgentsPage({ params: { domain } }: { params: { domain: string } }) {
+export default async function AgentsPage(props: { params: Promise<{ domain: string }> }) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
return (
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
index c25f9a33..01a84447 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
@@ -10,17 +10,16 @@ interface CodePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
- domain: string;
}
-export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }: CodePreviewPanelProps) => {
+export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
getFileSource({
fileName: path,
repository: repoName,
branch: revisionName,
- }, domain),
- getRepoInfoByName(repoName, domain),
+ }),
+ getRepoInfoByName(repoName),
]);
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
@@ -34,6 +33,11 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }:
webUrl: repoInfoResponse.webUrl,
});
+ // @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis
+ // will break if the default branch is not main.
+ const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.webUrl ?
+ fileSourceResponse.webUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.webUrl;
+
return (
<>
@@ -45,10 +49,13 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }:
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
}}
+ branchDisplayName={revisionName}
/>
- {(fileSourceResponse.webUrl && codeHostInfo) && (
+
+ {(fileWebUrl && codeHostInfo) && (
+
{
- captureEvent('wa_browse_find_references_pressed', {});
+ captureEvent('wa_find_references_pressed', {
+ source: 'browse',
+ });
createAuditAction({
action: "user.performed_find_references",
metadata: {
@@ -160,7 +162,9 @@ export const PureCodePreviewPanel = ({
// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
- captureEvent('wa_browse_goto_definition_pressed', {});
+ captureEvent('wa_goto_definition_pressed', {
+ source: 'browse',
+ });
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx
index 17860185..cdb8f3ca 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx
@@ -1,11 +1,12 @@
'use client';
-import { useCallback, useRef } from "react";
+import { useRef } from "react";
import { FileTreeItem } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
-import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
+import { getBrowsePath } from "../../hooks/useBrowseNavigation";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useBrowseParams } from "../../hooks/useBrowseParams";
+import { useDomain } from "@/hooks/useDomain";
interface PureTreePreviewPanelProps {
items: FileTreeItem[];
@@ -13,18 +14,9 @@ interface PureTreePreviewPanelProps {
export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => {
const { repoName, revisionName } = useBrowseParams();
- const { navigateToPath } = useBrowseNavigation();
const scrollAreaRef = useRef(null);
-
- const onNodeClicked = useCallback((node: FileTreeItem) => {
- navigateToPath({
- repoName: repoName,
- revisionName: revisionName,
- path: node.path,
- pathType: node.type === 'tree' ? 'tree' : 'blob',
- });
- }, [navigateToPath, repoName, revisionName]);
-
+ const domain = useDomain();
+
return (
{
isActive={false}
depth={0}
isCollapseChevronVisible={false}
- onClick={() => onNodeClicked(item)}
parentRef={scrollAreaRef}
+ href={getBrowsePath({
+ repoName,
+ revisionName,
+ path: item.path,
+ pathType: item.type === 'tree' ? 'tree' : 'blob',
+ domain,
+ })}
/>
))}
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts
index 7f1076c3..cb0fe977 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts
@@ -9,7 +9,7 @@ const markDecoration = Decoration.mark({
});
const lineDecoration = Decoration.line({
- attributes: { class: "lineHighlight" },
+ attributes: { class: "cm-range-border-radius lineHighlight" },
});
export const rangeHighlightingExtension = (range: BrowseHighlightRange) => StateField.define({
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx
index f2484507..4a0c3857 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx
@@ -10,17 +10,16 @@ interface TreePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
- domain: string;
}
-export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => {
+export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePreviewPanelProps) => {
const [repoInfoResponse, folderContentsResponse] = await Promise.all([
- getRepoInfoByName(repoName, domain),
+ getRepoInfoByName(repoName),
getFolderContents({
repoName,
revisionName: revisionName ?? 'HEAD',
path,
- }, domain)
+ })
]);
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
@@ -39,6 +38,8 @@ export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }:
webUrl: repoInfoResponse.webUrl,
}}
pathType="tree"
+ isFileIconVisible={false}
+ branchDisplayName={revisionName}
/>
diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx
index 12689f86..84c87912 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx
@@ -5,14 +5,19 @@ import { Loader2 } from "lucide-react";
import { TreePreviewPanel } from "./components/treePreviewPanel";
interface BrowsePageProps {
- params: {
+ params: Promise<{
path: string[];
- domain: string;
- };
+ }>;
}
-export default async function BrowsePage({ params: { path: _rawPath, domain } }: BrowsePageProps) {
- const rawPath = decodeURIComponent(_rawPath.join('/'));
+export default async function BrowsePage(props: BrowsePageProps) {
+ const params = await props.params;
+
+ const {
+ path: _rawPath,
+ } = params;
+
+ const rawPath = _rawPath.join('/');
const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath);
return (
@@ -28,14 +33,12 @@ export default async function BrowsePage({ params: { path: _rawPath, domain } }:
path={path}
repoName={repoName}
revisionName={revisionName}
- domain={domain}
/>
) : (
)}
diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx
index d87eab85..0cfe720a 100644
--- a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx
+++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx
@@ -6,7 +6,6 @@ import { useHotkeys } from "react-hotkeys-hook";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
-import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState";
@@ -28,7 +27,6 @@ type SearchResult = {
export const FileSearchCommandDialog = () => {
const { repoName, revisionName } = useBrowseParams();
- const domain = useDomain();
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
const commandListRef = useRef
(null);
@@ -57,8 +55,8 @@ export const FileSearchCommandDialog = () => {
}, [isFileSearchOpen]);
const { data: files, isLoading, isError } = useQuery({
- queryKey: ['files', repoName, revisionName, domain],
- queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)),
+ queryKey: ['files', repoName, revisionName],
+ queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' })),
enabled: isFileSearchOpen,
});
diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts
index 83780153..0d79170e 100644
--- a/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts
+++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts
@@ -1,3 +1,5 @@
+'use client';
+
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
import { useCallback } from "react";
@@ -13,15 +15,47 @@ export type BrowseHighlightRange = {
export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange';
-interface NavigateToPathOptions {
+export interface GetBrowsePathProps {
repoName: string;
revisionName?: string;
path: string;
pathType: 'blob' | 'tree';
highlightRange?: BrowseHighlightRange;
setBrowseState?: Partial;
+ domain: string;
}
+export const getBrowsePath = ({
+ repoName,
+ revisionName = 'HEAD',
+ path,
+ pathType,
+ highlightRange,
+ setBrowseState,
+ domain,
+}: GetBrowsePathProps) => {
+ const params = new URLSearchParams();
+
+ if (highlightRange) {
+ const { start, end } = highlightRange;
+
+ if ('column' in start && 'column' in end) {
+ params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
+ } else {
+ params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`);
+ }
+ }
+
+ if (setBrowseState) {
+ params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
+ }
+
+ const encodedPath = encodeURIComponent(path);
+ const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
+ return browsePath;
+}
+
+
export const useBrowseNavigation = () => {
const router = useRouter();
const domain = useDomain();
@@ -33,24 +67,18 @@ export const useBrowseNavigation = () => {
pathType,
highlightRange,
setBrowseState,
- }: NavigateToPathOptions) => {
- const params = new URLSearchParams();
+ }: Omit) => {
+ const browsePath = getBrowsePath({
+ repoName,
+ revisionName,
+ path,
+ pathType,
+ highlightRange,
+ setBrowseState,
+ domain,
+ });
- if (highlightRange) {
- const { start, end } = highlightRange;
-
- if ('column' in start && 'column' in end) {
- params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
- } else {
- params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`);
- }
- }
-
- if (setBrowseState) {
- params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
- }
-
- router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`);
+ router.push(browsePath);
}, [domain, router]);
return {
diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts
new file mode 100644
index 00000000..fcf29be8
--- /dev/null
+++ b/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts
@@ -0,0 +1,32 @@
+'use client';
+
+import { useMemo } from "react";
+import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation";
+import { useDomain } from "@/hooks/useDomain";
+
+export const useBrowsePath = ({
+ repoName,
+ revisionName,
+ path,
+ pathType,
+ highlightRange,
+ setBrowseState,
+}: Omit) => {
+ const domain = useDomain();
+
+ const browsePath = useMemo(() => {
+ return getBrowsePath({
+ repoName,
+ revisionName,
+ path,
+ pathType,
+ highlightRange,
+ setBrowseState,
+ domain,
+ });
+ }, [repoName, revisionName, path, pathType, highlightRange, setBrowseState, domain]);
+
+ return {
+ path: browsePath,
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/browse/hooks/utils.test.ts b/packages/web/src/app/[domain]/browse/hooks/utils.test.ts
index bbf048b6..a50214c8 100644
--- a/packages/web/src/app/[domain]/browse/hooks/utils.test.ts
+++ b/packages/web/src/app/[domain]/browse/hooks/utils.test.ts
@@ -98,6 +98,26 @@ describe('getBrowseParamsFromPathParam', () => {
pathType: 'blob',
});
});
+
+ it('should decode paths with percent symbols in path', () => {
+ const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/%25hello%25%2Fworld.c');
+ expect(result).toEqual({
+ repoName: 'github.com/sourcebot-dev/zoekt',
+ revisionName: 'HEAD',
+ path: '%hello%/world.c',
+ pathType: 'blob',
+ });
+ });
+
+ it('should decode paths with @ symbol encoded', () => {
+ const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt%40HEAD/-/blob/file.txt');
+ expect(result).toEqual({
+ repoName: 'github.com/sourcebot-dev/zoekt',
+ revisionName: 'HEAD',
+ path: 'file.txt',
+ pathType: 'blob',
+ });
+ })
});
describe('different revision formats', () => {
diff --git a/packages/web/src/app/[domain]/browse/hooks/utils.ts b/packages/web/src/app/[domain]/browse/hooks/utils.ts
index 307dd018..ba3214fb 100644
--- a/packages/web/src/app/[domain]/browse/hooks/utils.ts
+++ b/packages/web/src/app/[domain]/browse/hooks/utils.ts
@@ -5,7 +5,7 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob)/" pattern`);
}
- const repoAndRevisionPart = pathParam.substring(0, sentinelIndex);
+ const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex));
const lastAtIndex = repoAndRevisionPart.lastIndexOf('@');
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx
index f4c15c66..6807a38f 100644
--- a/packages/web/src/app/[domain]/browse/layout.tsx
+++ b/packages/web/src/app/[domain]/browse/layout.tsx
@@ -6,33 +6,33 @@ import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle
import { BrowseStateProvider } from "./browseStateProvider";
import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
import { TopBar } from "@/app/[domain]/components/topBar";
-import { Separator } from '@/components/ui/separator';
import { useBrowseParams } from "./hooks/useBrowseParams";
import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog";
+import { useDomain } from "@/hooks/useDomain";
+import { SearchBar } from "../components/searchBar";
interface LayoutProps {
children: React.ReactNode;
- params: {
- domain: string;
- }
}
export default function Layout({
- children: codePreviewPanel,
- params,
+ children,
}: LayoutProps) {
const { repoName, revisionName } = useBrowseParams();
+ const domain = useDomain();
return (
-
-
+
-
-
+
@@ -53,7 +53,7 @@ export default function Layout({
order={1}
id="code-preview-panel"
>
- {codePreviewPanel}
+ {children}
{
+ // @note: we are guaranteed to have a chatId because this component will only be
+ // mounted when on a /chat/[id] route.
+ const chatId = useChatId()!;
+ const [inputMessage, setInputMessage] = useState | undefined>(undefined);
+ const [chatState, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null);
+
+ // Use the last user's last message to determine what repos and contexts we should select by default.
+ const lastUserMessage = messages.findLast((message) => message.role === "user");
+ const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? [];
+ const [selectedSearchScopes, setSelectedSearchScopes] = useState(defaultSelectedSearchScopes);
+
+ useEffect(() => {
+ if (!chatState) {
+ return;
+ }
+
+ try {
+ setInputMessage(chatState.inputMessage);
+ setSelectedSearchScopes(chatState.selectedSearchScopes);
+ } catch {
+ console.error('Invalid chat state in session storage');
+ } finally {
+ setChatState(null);
+ }
+
+ }, [chatState, setChatState]);
+
+ return (
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/[id]/page.tsx b/packages/web/src/app/[domain]/chat/[id]/page.tsx
new file mode 100644
index 00000000..d37076cd
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/[id]/page.tsx
@@ -0,0 +1,91 @@
+import { getRepos, getSearchContexts } from '@/actions';
+import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo } from '@/features/chat/actions';
+import { ServiceErrorException } from '@/lib/serviceError';
+import { isServiceError } from '@/lib/utils';
+import { ChatThreadPanel } from './components/chatThreadPanel';
+import { notFound } from 'next/navigation';
+import { StatusCodes } from 'http-status-codes';
+import { TopBar } from '../../components/topBar';
+import { ChatName } from '../components/chatName';
+import { auth } from '@/auth';
+import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
+import { ChatSidePanel } from '../components/chatSidePanel';
+import { ResizablePanelGroup } from '@/components/ui/resizable';
+
+interface PageProps {
+ params: Promise<{
+ domain: string;
+ id: string;
+ }>;
+}
+
+export default async function Page(props: PageProps) {
+ const params = await props.params;
+ const languageModels = await getConfiguredLanguageModelsInfo();
+ const repos = await getRepos();
+ const searchContexts = await getSearchContexts(params.domain);
+ const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
+ const session = await auth();
+ const chatHistory = session ? await getUserChatHistory(params.domain) : [];
+
+ if (isServiceError(chatHistory)) {
+ throw new ServiceErrorException(chatHistory);
+ }
+
+ if (isServiceError(repos)) {
+ throw new ServiceErrorException(repos);
+ }
+
+ if (isServiceError(searchContexts)) {
+ throw new ServiceErrorException(searchContexts);
+ }
+
+ if (isServiceError(chatInfo)) {
+ if (chatInfo.statusCode === StatusCodes.NOT_FOUND) {
+ return notFound();
+ }
+
+ throw new ServiceErrorException(chatInfo);
+ }
+
+ const { messages, name, visibility, isReadonly } = chatInfo;
+
+ const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
+
+ return (
+ <>
+
+
+ /
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/components/chatName.tsx b/packages/web/src/app/[domain]/chat/components/chatName.tsx
new file mode 100644
index 00000000..b305d3d7
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/components/chatName.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { useToast } from "@/components/hooks/use-toast";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { updateChatName } from "@/features/chat/actions";
+import { useDomain } from "@/hooks/useDomain";
+import { isServiceError } from "@/lib/utils";
+import { GlobeIcon } from "@radix-ui/react-icons";
+import { ChatVisibility } from "@sourcebot/db";
+import { LockIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useState } from "react";
+import { RenameChatDialog } from "./renameChatDialog";
+
+interface ChatNameProps {
+ name: string | null;
+ visibility: ChatVisibility;
+ id: string;
+ isReadonly: boolean;
+}
+
+export const ChatName = ({ name, visibility, id, isReadonly }: ChatNameProps) => {
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const { toast } = useToast();
+ const domain = useDomain();
+ const router = useRouter();
+
+ const onRenameChat = useCallback(async (name: string) => {
+
+ const response = await updateChatName({
+ chatId: id,
+ name: name,
+ }, domain);
+
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to rename chat. Reason: ${response.message}`
+ });
+ } else {
+ toast({
+ description: `✅ Chat renamed successfully`
+ });
+ router.refresh();
+ }
+ }, [id, domain, toast, router]);
+
+ return (
+ <>
+
+
+
+ {
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ {name ?? 'Untitled chat'}
+
+
+
+ Rename chat
+
+
+ {visibility && (
+
+
+
+
+ {visibility === ChatVisibility.PUBLIC ? (
+
+ ) : (
+
+ )}
+ {visibility === ChatVisibility.PUBLIC ? (isReadonly ? 'Public (Read-only)' : 'Public') : 'Private'}
+
+
+
+
+ {visibility === ChatVisibility.PUBLIC ? `Anyone with the link can view this chat${!isReadonly ? ' and ask follow-up questions' : ''}.` : 'Only you can view and edit this chat.'}
+
+
+ )}
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx b/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx
new file mode 100644
index 00000000..16e955f8
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx
@@ -0,0 +1,264 @@
+'use client';
+
+import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
+import { useToast } from "@/components/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { ResizablePanel } from "@/components/ui/resizable";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { deleteChat, updateChatName } from "@/features/chat/actions";
+import { useDomain } from "@/hooks/useDomain";
+import { cn, isServiceError } from "@/lib/utils";
+import { CirclePlusIcon, EllipsisIcon, PencilIcon, TrashIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useRef, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import {
+ GoSidebarCollapse as ExpandIcon,
+} from "react-icons/go";
+import { ImperativePanelHandle } from "react-resizable-panels";
+import { useChatId } from "../useChatId";
+import { RenameChatDialog } from "./renameChatDialog";
+import { DeleteChatDialog } from "./deleteChatDialog";
+import Link from "next/link";
+
+interface ChatSidePanelProps {
+ order: number;
+ chatHistory: {
+ id: string;
+ name: string | null;
+ createdAt: Date;
+ }[];
+ isAuthenticated: boolean;
+ isCollapsedInitially: boolean;
+}
+
+export const ChatSidePanel = ({
+ order,
+ chatHistory,
+ isAuthenticated,
+ isCollapsedInitially,
+}: ChatSidePanelProps) => {
+ const domain = useDomain();
+ const [isCollapsed, setIsCollapsed] = useState(isCollapsedInitially);
+ const sidePanelRef = useRef(null);
+ const router = useRouter();
+ const { toast } = useToast();
+ const chatId = useChatId();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [chatIdToRename, setChatIdToRename] = useState(null);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [chatIdToDelete, setChatIdToDelete] = useState(null);
+
+ useHotkeys("mod+b", () => {
+ if (isCollapsed) {
+ sidePanelRef.current?.expand();
+ } else {
+ sidePanelRef.current?.collapse();
+ }
+ }, {
+ enableOnFormTags: true,
+ enableOnContentEditable: true,
+ description: "Toggle side panel",
+ });
+
+ const onRenameChat = useCallback(async (name: string, chatId: string) => {
+ if (!chatId) {
+ return;
+ }
+
+ const response = await updateChatName({
+ chatId,
+ name: name,
+ }, domain);
+
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to rename chat. Reason: ${response.message}`
+ });
+ } else {
+ toast({
+ description: `✅ Chat renamed successfully`
+ });
+ router.refresh();
+ }
+ }, [router, toast, domain]);
+
+ const onDeleteChat = useCallback(async (chatIdToDelete: string) => {
+ if (!chatIdToDelete) {
+ return;
+ }
+
+ const response = await deleteChat({ chatId: chatIdToDelete }, domain);
+
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to delete chat. Reason: ${response.message}`
+ });
+ } else {
+ toast({
+ description: `✅ Chat deleted successfully`
+ });
+
+ // If we just deleted the current chat, navigate to new chat
+ if (chatIdToDelete === chatId) {
+ router.push(`/${domain}/chat`);
+ }
+
+ router.refresh();
+ }
+ }, [chatId, router, toast, domain]);
+
+ return (
+ <>
+ setIsCollapsed(true)}
+ onExpand={() => setIsCollapsed(false)}
+ >
+
+
+ {
+ router.push(`/${domain}/chat`);
+ }}
+ >
+
+ New Chat
+
+
+
+ Recent Chats
+
+ {!isAuthenticated ? (
+
+
+
+ Sign in
+ to access your chat history.
+
+
+ ) : chatHistory.length === 0 ? (
+
+
Recent chats will appear here.
+
+ ) : chatHistory.map((chat) => (
+
{
+ router.push(`/${domain}/chat/${chat.id}`);
+ }}
+ >
+
{chat.name ?? 'Untitled chat'}
+
+
+ {
+ e.stopPropagation();
+ }}
+ >
+
+
+
+
+ {
+ e.stopPropagation();
+ setChatIdToRename(chat.id);
+ setIsRenameDialogOpen(true);
+ }}
+ >
+
+ Rename
+
+ {
+ e.stopPropagation();
+ setChatIdToDelete(chat.id);
+ setIsDeleteDialogOpen(true);
+ }}
+ >
+
+ Delete
+
+
+
+
+ ))}
+
+
+
+
+
+ {isCollapsed && (
+
+
+
+ {
+ sidePanelRef.current?.expand();
+ }}
+ >
+
+
+
+
+
+
+ Open side panel
+
+
+
+ )}
+ {
+ if (chatIdToRename) {
+ onRenameChat(name, chatIdToRename);
+ }
+ }}
+ currentName={chatHistory?.find((chat) => chat.id === chatIdToRename)?.name ?? ""}
+ />
+ {
+ if (chatIdToDelete) {
+ onDeleteChat(chatIdToDelete);
+ }
+ }}
+ />
+ >
+ )
+}
diff --git a/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx b/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx
new file mode 100644
index 00000000..095f4054
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useCallback } from "react";
+
+interface DeleteChatDialogProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onDelete: () => void;
+}
+
+export const DeleteChatDialog = ({ isOpen, onOpenChange, onDelete }: DeleteChatDialogProps) => {
+ const handleDelete = useCallback(() => {
+ onDelete();
+ onOpenChange(false);
+ }, [onDelete, onOpenChange]);
+
+ return (
+
+
+
+ Delete chat?
+
+ The chat will be deleted and removed from your chat history. This action cannot be undone.
+
+
+
+ {
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+ );
+};
+
diff --git a/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx
new file mode 100644
index 00000000..91ae3f96
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { ResizablePanel } from "@/components/ui/resizable";
+import { ChatBox } from "@/features/chat/components/chatBox";
+import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
+import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
+import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
+import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
+import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
+import { useCallback, useState } from "react";
+import { Descendant } from "slate";
+import { useLocalStorage } from "usehooks-ts";
+
+interface NewChatPanelProps {
+ languageModels: LanguageModelInfo[];
+ repos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ order: number;
+}
+
+export const NewChatPanel = ({
+ languageModels,
+ repos,
+ searchContexts,
+ order,
+}: NewChatPanelProps) => {
+ const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false });
+ const { createNewChatThread, isLoading } = useCreateNewChatThread();
+ const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
+
+ const onSubmit = useCallback((children: Descendant[]) => {
+ createNewChatThread(children, selectedSearchScopes);
+ }, [createNewChatThread, selectedSearchScopes]);
+
+
+ return (
+
+
+
What can I help you understand?
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx b/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx
new file mode 100644
index 00000000..e22277e1
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+interface RenameChatDialogProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onRename: (name: string) => void;
+ currentName: string;
+}
+
+export const RenameChatDialog = ({ isOpen, onOpenChange, onRename, currentName }: RenameChatDialogProps) => {
+ const formSchema = z.object({
+ name: z.string().min(1),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: "",
+ },
+ });
+
+ useEffect(() => {
+ form.reset({
+ name: currentName,
+ });
+ }, [currentName, form]);
+
+ const onSubmit = (data: z.infer) => {
+ onRename(data.name);
+ form.reset();
+ onOpenChange(false);
+ }
+
+ return (
+
+
+
+ Rename Chat
+
+ {`Rename "${currentName ?? 'untitled chat'}" to a new name.`}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+
+ {
+ form.handleSubmit(onSubmit)();
+ }}
+ >
+ Rename
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/layout.tsx b/packages/web/src/app/[domain]/chat/layout.tsx
new file mode 100644
index 00000000..e82ea91f
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/layout.tsx
@@ -0,0 +1,18 @@
+import { NavigationGuardProvider } from 'next-navigation-guard';
+
+interface LayoutProps {
+ children: React.ReactNode;
+}
+
+export default async function Layout({ children }: LayoutProps) {
+
+ return (
+ // @note: we use a navigation guard here since we don't support resuming streams yet.
+ // @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams
+
+
+ {children}
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/page.tsx b/packages/web/src/app/[domain]/chat/page.tsx
new file mode 100644
index 00000000..8bdddf9e
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/page.tsx
@@ -0,0 +1,64 @@
+import { getRepos, getSearchContexts } from "@/actions";
+import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { NewChatPanel } from "./components/newChatPanel";
+import { TopBar } from "../components/topBar";
+import { ResizablePanelGroup } from "@/components/ui/resizable";
+import { ChatSidePanel } from "./components/chatSidePanel";
+import { auth } from "@/auth";
+import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
+
+interface PageProps {
+ params: Promise<{
+ domain: string;
+ }>;
+}
+
+export default async function Page(props: PageProps) {
+ const params = await props.params;
+ const languageModels = await getConfiguredLanguageModelsInfo();
+ const repos = await getRepos();
+ const searchContexts = await getSearchContexts(params.domain);
+ const session = await auth();
+ const chatHistory = session ? await getUserChatHistory(params.domain) : [];
+
+ if (isServiceError(chatHistory)) {
+ throw new ServiceErrorException(chatHistory);
+ }
+
+ if (isServiceError(repos)) {
+ throw new ServiceErrorException(repos);
+ }
+
+ if (isServiceError(searchContexts)) {
+ throw new ServiceErrorException(searchContexts);
+ }
+
+ const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/chat/useChatId.ts b/packages/web/src/app/[domain]/chat/useChatId.ts
new file mode 100644
index 00000000..b42aa2a4
--- /dev/null
+++ b/packages/web/src/app/[domain]/chat/useChatId.ts
@@ -0,0 +1,8 @@
+'use client';
+
+import { useParams } from "next/navigation";
+
+export const useChatId = (): string | undefined => {
+ const { id: chatId } = useParams<{ id: string }>();
+ return chatId;
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx
deleted file mode 100644
index 52c762fc..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-'use client';
-
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
-import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
-import { bitbucketCloudQuickActions } from "../../connections/quickActions";
-
-interface BitbucketCloudConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => {
- const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
- const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
- const hasWorkspaces = config.workspaces && config.workspaces.length > 0 && config.workspaces.some(w => w.trim().length > 0);
-
- if (!hasProjects && !hasRepos && !hasWorkspaces) {
- return {
- message: "At least one project, repository, or workspace must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-};
-
-export const BitbucketCloudConnectionCreationForm = ({ onCreated }: BitbucketCloudConnectionCreationFormProps) => {
- const defaultConfig: BitbucketConnectionConfig = {
- type: 'bitbucket',
- deploymentType: 'cloud',
- }
-
- return (
-
- type="bitbucket-cloud"
- title="Create a Bitbucket Cloud connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={bitbucketSchema}
- additionalConfigValidation={additionalConfigValidation}
- quickActions={bitbucketCloudQuickActions}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx
deleted file mode 100644
index 5065de00..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client';
-
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
-import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
-import { bitbucketDataCenterQuickActions } from "../../connections/quickActions";
-
-interface BitbucketDataCenterConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => {
- const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
- const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
-
- if (!hasProjects && !hasRepos) {
- return {
- message: "At least one project or repository must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-};
-
-export const BitbucketDataCenterConnectionCreationForm = ({ onCreated }: BitbucketDataCenterConnectionCreationFormProps) => {
- const defaultConfig: BitbucketConnectionConfig = {
- type: 'bitbucket',
- deploymentType: 'server',
- }
-
- return (
-
- type="bitbucket-server"
- title="Create a Bitbucket Data Center connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={bitbucketSchema}
- additionalConfigValidation={additionalConfigValidation}
- quickActions={bitbucketDataCenterQuickActions}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx
deleted file mode 100644
index 64b6b10b..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-'use client';
-
-import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
-import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
-import { gerritQuickActions } from "../../connections/quickActions";
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-
-interface GerritConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: GerritConnectionConfig): { message: string, isValid: boolean } => {
- const hasProjects = config.projects && config.projects.length > 0;
-
- if (!hasProjects) {
- return {
- message: "At least one project must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-}
-
-export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
- const defaultConfig: GerritConnectionConfig = {
- type: 'gerrit',
- url: "https://gerrit.example.com"
- }
-
- return (
-
- type="gerrit"
- title="Create a Gerrit connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={gerritSchema}
- quickActions={gerritQuickActions}
- additionalConfigValidation={additionalConfigValidation}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx
deleted file mode 100644
index f19c441c..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client';
-
-import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
-import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
-import { giteaQuickActions } from "../../connections/quickActions";
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-
-interface GiteaConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: GiteaConnectionConfig): { message: string, isValid: boolean } => {
- const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
- const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
- const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
-
- if (!hasOrgs && !hasUsers && !hasRepos) {
- return {
- message: "At least one organization, user, or repository must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-}
-
-export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
- const defaultConfig: GiteaConnectionConfig = {
- type: 'gitea',
- }
-
- return (
-
- type="gitea"
- title="Create a Gitea connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={giteaSchema}
- quickActions={giteaQuickActions}
- additionalConfigValidation={additionalConfigValidation}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx
deleted file mode 100644
index 80446ab4..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client';
-
-import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
-import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
-import { githubQuickActions } from "../../connections/quickActions";
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-
-interface GitHubConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: GithubConnectionConfig): { message: string, isValid: boolean } => {
- const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
- const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
- const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
-
- if (!hasRepos && !hasOrgs && !hasUsers) {
- return {
- message: "At least one repository, organization, or user must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-};
-
-export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
- const defaultConfig: GithubConnectionConfig = {
- type: 'github',
- }
-
- return (
-
- type="github"
- title="Create a GitHub connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={githubSchema}
- additionalConfigValidation={additionalConfigValidation}
- quickActions={githubQuickActions}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx
deleted file mode 100644
index d21823f6..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client';
-
-import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
-import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
-import { gitlabQuickActions } from "../../connections/quickActions";
-import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
-
-interface GitLabConnectionCreationFormProps {
- onCreated?: (id: number) => void;
-}
-
-const additionalConfigValidation = (config: GitlabConnectionConfig): { message: string, isValid: boolean } => {
- const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
- const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
- const hasGroups = config.groups && config.groups.length > 0 && config.groups.some(g => g.trim().length > 0);
-
- if (!hasProjects && !hasUsers && !hasGroups) {
- return {
- message: "At least one project, user, or group must be specified",
- isValid: false,
- }
- }
-
- return {
- message: "Valid",
- isValid: true,
- }
-}
-
-export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
- const defaultConfig: GitlabConnectionConfig = {
- type: 'gitlab',
- }
-
- return (
-
- type="gitlab"
- title="Create a GitLab connection"
- defaultValues={{
- config: JSON.stringify(defaultConfig, null, 2),
- }}
- schema={gitlabSchema}
- quickActions={gitlabQuickActions}
- additionalConfigValidation={additionalConfigValidation}
- onCreated={onCreated}
- />
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts
deleted file mode 100644
index db4bcd0f..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm";
-export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm";
-export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm";
-export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm";
-export { BitbucketCloudConnectionCreationForm } from "./bitbucketCloudConnectionCreationForm";
-export { BitbucketDataCenterConnectionCreationForm } from "./bitbucketDataCenterConnectionCreationForm";
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx
deleted file mode 100644
index 6bfe4baf..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client';
-
-import { getSecrets } from "@/actions";
-import { Button } from "@/components/ui/button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Separator } from "@/components/ui/separator";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-import { useDomain } from "@/hooks/useDomain";
-import { cn, CodeHostType, isDefined, isServiceError, unwrapServiceError } from "@/lib/utils";
-import { useQuery } from "@tanstack/react-query";
-import { Check, ChevronsUpDown, Loader2, PlusCircleIcon, TriangleAlert } from "lucide-react";
-import { useCallback, useState } from "react";
-import { ImportSecretDialog } from "../importSecretDialog";
-
-interface SecretComboBoxProps {
- isDisabled: boolean;
- codeHostType: CodeHostType;
- secretKey?: string;
- onSecretChange: (secretKey: string) => void;
-}
-
-export const SecretCombobox = ({
- isDisabled,
- codeHostType,
- secretKey,
- onSecretChange,
-}: SecretComboBoxProps) => {
- const [searchFilter, setSearchFilter] = useState("");
- const domain = useDomain();
- const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
- const captureEvent = useCaptureEvent();
-
- const { data: secrets, isPending, isError, refetch } = useQuery({
- queryKey: ["secrets", domain],
- queryFn: () => unwrapServiceError(getSecrets(domain)),
- });
-
- const onSecretCreated = useCallback((key: string) => {
- onSecretChange(key);
- refetch();
- }, [onSecretChange, refetch]);
-
- return (
- <>
-
-
-
-
- {!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && (
-
-
-
- e.preventDefault()}
- >
-
-
-
- The secret you selected does not exist.
-
-
-
- )}
- {isDefined(secretKey) ? secretKey : "Select secret"}
-
-
-
-
- {isPending ? (
-
-
-
- ) : isError ? (
- Failed to load secrets
- ) : secrets.length > 0 && (
- <>
-
- setSearchFilter(value)}
- />
-
-
- No secrets found
- {`Your search term "${searchFilter}" did not match any secrets.`}
-
-
- {secrets.map(({ key }) => (
- {
- onSecretChange(key);
- }}
- >
- {key}
-
-
- ))}
-
-
-
-
- >
- )}
- {
- setIsCreateSecretDialogOpen(true);
- captureEvent('wa_secret_combobox_import_secret_pressed', {
- type: codeHostType,
- });
- }}
- className={cn(
- "w-full justify-start gap-1.5 p-2",
- secrets && !isServiceError(secrets) && secrets.length > 0 && "my-2"
- )}
- >
-
- Import a secret
-
-
-
-
- >
- )
-}
diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx
deleted file mode 100644
index 859d87da..00000000
--- a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx
+++ /dev/null
@@ -1,239 +0,0 @@
-
-'use client';
-
-import { checkIfSecretExists, createConnection } from "@/actions";
-import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
-import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
-import { useToast } from "@/components/hooks/use-toast";
-import { Button } from "@/components/ui/button";
-import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { CodeHostType, isServiceError, isAuthSupportedForCodeHost } from "@/lib/utils";
-import { cn } from "@/lib/utils";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Schema } from "ajv";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import ConfigEditor, { isConfigValidJson, onQuickAction, QuickActionFn } from "../configEditor";
-import { useDomain } from "@/hooks/useDomain";
-import { InfoIcon, Loader2 } from "lucide-react";
-import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
-import { SecretCombobox } from "./secretCombobox";
-import strings from "@/lib/strings";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-
-interface SharedConnectionCreationFormProps {
- type: CodeHostType;
- defaultValues: {
- name?: string;
- config: string;
- };
- title: string;
- schema: Schema;
- quickActions?: {
- name: string;
- fn: QuickActionFn;
- }[],
- className?: string;
- onCreated?: (id: number) => void;
- additionalConfigValidation?: (config: T) => { message: string, isValid: boolean };
-}
-
-
-export default function SharedConnectionCreationForm({
- type,
- defaultValues,
- title,
- schema,
- quickActions,
- className,
- onCreated,
- additionalConfigValidation
-}: SharedConnectionCreationFormProps) {
- const { toast } = useToast();
- const domain = useDomain();
- const editorRef = useRef(null);
- const captureEvent = useCaptureEvent();
- const formSchema = useMemo(() => {
- return z.object({
- name: z.string().min(1),
- config: createZodConnectionConfigValidator(schema, additionalConfigValidation),
- secretKey: z.string().optional().refine(async (secretKey) => {
- if (!secretKey) {
- return true;
- }
-
- return checkIfSecretExists(secretKey, domain);
- }, { message: "Secret not found" }),
- });
- }, [schema, domain, additionalConfigValidation]);
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: defaultValues,
- });
- const { isSubmitting } = form.formState;
-
- const onSubmit = useCallback(async (data: z.infer) => {
- const response = await createConnection(data.name, type, data.config, domain);
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to create connection. Reason: ${response.message}`
- });
- captureEvent('wa_create_connection_fail', {
- type: type,
- error: response.message,
- });
- } else {
- toast({
- description: `✅ Connection created successfully.`
- });
- captureEvent('wa_create_connection_success', {
- type: type,
- });
- onCreated?.(response.id);
- }
- }, [domain, toast, type, onCreated, captureEvent]);
-
- const onConfigChange = useCallback((value: string) => {
- form.setValue("config", value);
- const isValid = isConfigValidJson(value);
- setIsSecretsDisabled(!isValid);
- if (isValid) {
- const configJson = JSON.parse(value);
- if (configJson.token?.secret !== undefined) {
- form.setValue("secretKey", configJson.token.secret);
- } else {
- form.setValue("secretKey", undefined);
- }
- }
- }, [form]);
-
- // Run onConfigChange on mount to set the initial secret key
- useEffect(() => {
- onConfigChange(defaultValues.config);
- }, [defaultValues, onConfigChange]);
-
- const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
-
- return (
-
-
-
-
-
{title}
-
-
- Connections are used to specify what repositories you want Sourcebot to sync.
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/copyIconButton.tsx b/packages/web/src/app/[domain]/components/copyIconButton.tsx
new file mode 100644
index 00000000..4c2b0996
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/copyIconButton.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { CheckCircle2, Copy } from "lucide-react";
+import { useCallback, useState } from "react";
+
+interface CopyIconButtonProps {
+ onCopy: () => boolean;
+ className?: string;
+}
+
+export const CopyIconButton = ({ onCopy, className }: CopyIconButtonProps) => {
+ const [copied, setCopied] = useState(false);
+
+ const onClick = useCallback(() => {
+ const success = onCopy();
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ }, [onCopy]);
+
+ return (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx
index 2f024a61..4f7810aa 100644
--- a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx
+++ b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx
@@ -10,8 +10,8 @@ import { env } from "@/env.mjs";
import { useQuery } from "@tanstack/react-query";
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
import { getConnections } from "@/actions";
-import { getRepos } from "@/actions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { getRepos } from "@/app/api/(client)/client";
export const ErrorNavIndicator = () => {
const domain = useDomain();
@@ -19,7 +19,7 @@ export const ErrorNavIndicator = () => {
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
queryKey: ['repos', domain],
- queryFn: () => unwrapServiceError(getRepos(domain)),
+ queryFn: () => unwrapServiceError(getRepos()),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
@@ -106,24 +106,20 @@ export const ErrorNavIndicator = () => {
{repos
.slice(0, 10)
- .filter(item => item.linkedConnections.length > 0) // edge case: don't show repos that are orphaned and awaiting gc.
.map(repo => (
- // Link to the first connection for the repo
- captureEvent('wa_error_nav_job_pressed', {})}>
-
-
-
- {repo.repoName}
-
-
- {repo.repoName}
-
-
-
-
+
+
+
+ {repo.repoName}
+
+
+ {repo.repoName}
+
+
+
))}
{repos.length > 10 && (
diff --git a/packages/web/src/app/[domain]/components/githubStarToast.tsx b/packages/web/src/app/[domain]/components/githubStarToast.tsx
new file mode 100644
index 00000000..2e482ff1
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/githubStarToast.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { useToast } from "@/components/hooks/use-toast";
+import { ToastAction } from "@/components/ui/toast";
+import { useEffect } from "react";
+import { GitHubLogoIcon } from "@radix-ui/react-icons";
+import { captureEvent } from "@/hooks/useCaptureEvent";
+
+const POPUP_SHOWN_COOKIE = "github_popup_shown";
+const POPUP_START_TIME_COOKIE = "github_popup_start_time";
+const POPUP_DELAY_S = 60;
+const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
+
+function getCookie(name: string): string | null {
+ if (typeof document === "undefined") return null;
+
+ const cookies = document.cookie.split(';').map(cookie => cookie.trim());
+ const targetCookie = cookies.find(cookie => cookie.startsWith(`${name}=`));
+
+ if (!targetCookie) return null;
+
+ return targetCookie.substring(`${name}=`.length);
+}
+
+function setCookie(name: string, value: string, days: number = 365) {
+ if (typeof document === "undefined") return;
+
+ try {
+ const expires = new Date();
+ expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
+ document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
+ } catch (error) {
+ console.warn('Failed to set GitHub popup cookie:', error);
+ }
+}
+
+export const GitHubStarToast = () => {
+ const { toast } = useToast();
+
+ useEffect(() => {
+ const hasShownPopup = getCookie(POPUP_SHOWN_COOKIE);
+ const startTime = getCookie(POPUP_START_TIME_COOKIE);
+
+ if (hasShownPopup) {
+ return;
+ }
+
+ const currentTime = Date.now();
+ if (!startTime) {
+ setCookie(POPUP_START_TIME_COOKIE, currentTime.toString());
+ return;
+ }
+
+ const elapsed = currentTime - parseInt(startTime, 10);
+ if (elapsed >= (POPUP_DELAY_S * 1000)) {
+ toast({
+ title: "Star us on GitHub ❤️",
+ description: "If you've found Sourcebot useful, please consider starring us on GitHub. Your support means a lot!",
+ duration: 15 * 1000,
+ action: (
+
+
{
+ captureEvent('wa_github_star_toast_clicked', {});
+ window.open(SOURCEBOT_GITHUB_URL, "_blank");
+ }}
+ >
+
+
+ Sourcebot
+
+
+
+ )
+ });
+
+ captureEvent('wa_github_star_toast_displayed', {});
+ setCookie(POPUP_SHOWN_COOKIE, "true");
+ }
+ }, [toast]);
+
+ return null;
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx
new file mode 100644
index 00000000..327cd297
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { Separator } from "@/components/ui/separator";
+import { ChatBox } from "@/features/chat/components/chatBox";
+import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
+import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
+import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
+import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
+import { useCallback, useState } from "react";
+import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
+import { useLocalStorage } from "usehooks-ts";
+import { DemoExamples } from "@/types";
+import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
+import { AgenticSearchTutorialDialog } from "./agenticSearchTutorialDialog";
+import { setAgenticSearchTutorialDismissedCookie } from "@/actions";
+import { RepositorySnapshot } from "./repositorySnapshot";
+
+interface AgenticSearchProps {
+ searchModeSelectorProps: SearchModeSelectorProps;
+ languageModels: LanguageModelInfo[];
+ repos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ chatHistory: {
+ id: string;
+ createdAt: Date;
+ name: string | null;
+ }[];
+ demoExamples: DemoExamples | undefined;
+ isTutorialDismissed: boolean;
+}
+
+export const AgenticSearch = ({
+ searchModeSelectorProps,
+ languageModels,
+ repos,
+ searchContexts,
+ demoExamples,
+ isTutorialDismissed,
+}: AgenticSearchProps) => {
+ const { createNewChatThread, isLoading } = useCreateNewChatThread();
+ const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false });
+ const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
+
+ const [isTutorialOpen, setIsTutorialOpen] = useState(!isTutorialDismissed);
+ const onTutorialDismissed = useCallback(() => {
+ setIsTutorialOpen(false);
+ setAgenticSearchTutorialDismissedCookie(true);
+ }, []);
+
+ return (
+
+
+
{
+ createNewChatThread(children, selectedSearchScopes);
+ }}
+ className="min-h-[50px]"
+ isRedirecting={isLoading}
+ languageModels={languageModels}
+ selectedSearchScopes={selectedSearchScopes}
+ searchContexts={searchContexts}
+ onContextSelectorOpenChanged={setIsContextSelectorOpen}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {demoExamples && (
+
+ )}
+
+ {isTutorialOpen && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/homepage/agenticSearchTutorialDialog.tsx b/packages/web/src/app/[domain]/components/homepage/agenticSearchTutorialDialog.tsx
new file mode 100644
index 00000000..a44d6735
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/agenticSearchTutorialDialog.tsx
@@ -0,0 +1,339 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+import { ModelProviderLogo } from "@/features/chat/components/chatBox/modelProviderLogo"
+import { cn } from "@/lib/utils"
+import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
+import citationsDemo from "@/public/ask_sb_tutorial_citations.png"
+import searchScopeDemo from "@/public/ask_sb_tutorial_search_scope.png"
+import logoDarkSmall from "@/public/sb_logo_dark_small.png"
+import { useQuery } from "@tanstack/react-query"
+import {
+ ArrowLeftRightIcon,
+ AtSignIcon,
+ BookMarkedIcon,
+ BookTextIcon,
+ ChevronLeft,
+ ChevronRight,
+ CircleCheckIcon,
+ FileIcon,
+ FolderIcon,
+ GitCommitHorizontalIcon,
+ LibraryBigIcon,
+ ScanSearchIcon,
+ StarIcon,
+ TicketIcon,
+} from "lucide-react"
+import Image from "next/image"
+import Link from "next/link"
+import { useState } from "react"
+
+interface AgenticSearchTutorialDialogProps {
+ onClose: () => void
+}
+
+
+// Star button component that fetches GitHub star count
+const GitHubStarButton = () => {
+ const { data: starCount, isLoading, isError } = useQuery({
+ queryKey: ['github-stars', 'sourcebot-dev/sourcebot'],
+ queryFn: async () => {
+ const response = await fetch('https://api.github.com/repos/sourcebot-dev/sourcebot')
+ if (!response.ok) {
+ throw new Error('Failed to fetch star count')
+ }
+ const data = await response.json()
+ return data.stargazers_count as number;
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ gcTime: 30 * 60 * 1000, // 30 minutes
+ retry: 3,
+ retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
+ })
+
+ const formatStarCount = (count: number) => {
+ if (count >= 1000) {
+ return `${(count / 1000).toFixed(1)}k`
+ }
+ return count.toString()
+ }
+
+ return (
+ window.open('https://github.com/sourcebot-dev/sourcebot', '_blank')}
+ >
+
+
+ {
+ !isLoading && !isError && starCount ? `Star (${formatStarCount(starCount)})` : 'Star'
+ }
+
+
+ )
+}
+
+
+const tutorialSteps = [
+ {
+ leftContent: (
+
+
+
+ Ask Sourcebot.
+
+
+ Ask questions about your entire codebase in natural language.
+ Get back responses grounded in code with inline citations .
+
+
+ Ask Sourcebot is an agentic search tool that can answer questions about your codebase by searching, reading files, navigating references, and more. Supports any compatible LLM.
+
+
+
+
+ ),
+ rightContent: (
+
+ ),
+ },
+ {
+ leftContent: (
+
+
+
+ Search Scopes
+
+
+ {`When asking Sourcebot a question, you can select one or more scopes to focus the search.`}
+
+
+
+
There are two types of search scopes:
+
+
+ Repository : A single repository.
+
+
+
+ Reposet : A collection of repositories ( configuration docs).
+
+
+
+ ),
+ rightContent: (
+
+ ),
+ },
+ {
+ leftContent: (
+
+
+
+ Mentions
+
+
+ @ mention specific files to add them to the {`model's`} context. Suggestions will be scoped to the selected search scopes.
+
+
+
+
Coming soon
+
+
+
+ Directories : Include entire folders as context
+
+
+
+ Commits : Reference specific git commits
+
+
+
+ Docs : Link to external docs and wikis
+
+
+
+ Issues : GitHub issues, Jira tickets, and more
+
+
+
+
+ ),
+ rightContent: (
+
+ ),
+ },
+ {
+ leftContent: (
+
+
+
+ Inline Citations
+
+
+ {`Sourcebot searches your codebase and provides responses with clickable citations that link directly to relevant sections of code.`}
+
+
+ ),
+ rightContent: (
+
+ ),
+ },
+ {
+ leftContent: (
+
+
+
+ You're all set!
+
+
+ You can now ask Sourcebot any question about your codebase. Checkout the docs for more information.
+
+
+ Hit a bug? Open up an issue.
+
+
+ Feature request? Open a discussion.
+
+
+ Anything else? Contact us.
+
+
+ ),
+ rightContent: (
+
+
+
+
+ ),
+ },
+]
+
+export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDialogProps) => {
+ const [currentStep, setCurrentStep] = useState(0)
+
+ const nextStep = () => {
+ if (currentStep < tutorialSteps.length - 1) {
+ setCurrentStep(currentStep + 1)
+ }
+ }
+
+ const prevStep = () => {
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1)
+ }
+ }
+
+ const isLastStep = currentStep === tutorialSteps.length - 1
+ const isFirstStep = currentStep === 0
+ const currentStepData = tutorialSteps[currentStep];
+
+ return (
+
+
+
+ {/* Left Column (Text Content & Navigation) */}
+
+
+ {currentStepData.leftContent}
+
+
+ {/* Fixed bottom navigation for left column */}
+
+ {/* Left side: Previous button container */}
+
+
+
+ Previous
+
+
+
+ {/* Center: Progress dots */}
+
+ {tutorialSteps.map((_, index) => (
+
+ ))}
+
+
+ {/* Right side: Next/Start/Get Started button container */}
+
+ {isLastStep ? (
+
+ Get Started
+
+ ) : (
+
+ Next
+
+
+ )}
+
+
+
+
+ {/* Right Column (Image/Visual Content) */}
+
+
{currentStepData.rightContent}
+
+
+
+
+ )
+}
+
diff --git a/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx b/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx
new file mode 100644
index 00000000..31037607
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { useState } from "react";
+import Image from "next/image";
+import { Search, LibraryBigIcon, Code, Info } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Card } from "@/components/ui/card";
+import { CardContent } from "@/components/ui/card";
+import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types";
+import { cn, getCodeHostIcon } from "@/lib/utils";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
+
+interface AskSourcebotDemoCardsProps {
+ demoExamples: DemoExamples;
+}
+
+export const AskSourcebotDemoCards = ({
+ demoExamples,
+}: AskSourcebotDemoCardsProps) => {
+ const captureEvent = useCaptureEvent();
+ const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState(null);
+
+ const handleExampleClick = (example: DemoSearchExample) => {
+ captureEvent('wa_demo_search_example_card_pressed', {
+ exampleTitle: example.title,
+ exampleUrl: example.url || '',
+ });
+
+ if (example.url) {
+ window.open(example.url, '_blank');
+ }
+ }
+
+ const getSearchScopeIcon = (searchScope: DemoSearchScope, size: number = 20, isSelected: boolean = false) => {
+ const sizeClass = size === 12 ? "h-3 w-3" : "h-5 w-5";
+ const colorClass = isSelected ? "text-primary-foreground" : "text-muted-foreground";
+
+ if (searchScope.type === "reposet") {
+ return ;
+ }
+
+ if (searchScope.codeHostType) {
+ const codeHostIcon = getCodeHostIcon(searchScope.codeHostType);
+ if (codeHostIcon) {
+ // When selected, icons need to match the inverted badge colors
+ // In light mode selected: light icon on dark bg (invert)
+ // In dark mode selected: dark icon on light bg (no invert, override dark:invert)
+ const selectedIconClass = isSelected
+ ? "invert dark:invert-0"
+ : codeHostIcon.className;
+
+ return (
+
+ );
+ }
+ }
+
+ return ;
+ }
+
+ return (
+
+ {/* Example Searches Row */}
+
+
+
+
+
Community Ask Results
+
+
+
+ {/* Search Scope Filter */}
+
+
+
{
+ setSelectedFilterSearchScope(null);
+ }}
+ >
+ All
+
+ {demoExamples.searchScopes.map((searchScope) => (
+
{
+ setSelectedFilterSearchScope(searchScope.id);
+ }}
+ >
+ {getSearchScopeIcon(searchScope, 12, selectedFilterSearchScope === searchScope.id)}
+ {searchScope.displayName}
+
+ ))}
+
+
+
+ {demoExamples.searchExamples
+ .filter((example) => {
+ if (selectedFilterSearchScope === null) return true;
+ return example.searchScopes.includes(selectedFilterSearchScope);
+ })
+ .map((example) => {
+ const searchScopes = demoExamples.searchScopes.filter((searchScope) => example.searchScopes.includes(searchScope.id))
+ return (
+
handleExampleClick(example)}
+ >
+
+
+
+ {searchScopes.map((searchScope) => (
+
+ {getSearchScopeIcon(searchScope, 12)}
+ {searchScope.displayName}
+
+ ))}
+
+
+
+ {example.title}
+
+
+ {example.description}
+
+
+
+
+
+ )
+ })}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/components/homepage/index.tsx b/packages/web/src/app/[domain]/components/homepage/index.tsx
new file mode 100644
index 00000000..fa230288
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/index.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+import { LanguageModelInfo } from "@/features/chat/types";
+import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
+import { useHotkeys } from "react-hotkeys-hook";
+import { AgenticSearch } from "./agenticSearch";
+import { PreciseSearch } from "./preciseSearch";
+import { SearchMode } from "./toolbar";
+import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
+import { setSearchModeCookie } from "@/actions";
+import { useCallback, useState } from "react";
+import { DemoExamples } from "@/types";
+
+interface HomepageProps {
+ initialRepos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ languageModels: LanguageModelInfo[];
+ chatHistory: {
+ id: string;
+ createdAt: Date;
+ name: string | null;
+ }[];
+ initialSearchMode: SearchMode;
+ demoExamples: DemoExamples | undefined;
+ isAgenticSearchTutorialDismissed: boolean;
+}
+
+
+export const Homepage = ({
+ initialRepos,
+ searchContexts,
+ languageModels,
+ chatHistory,
+ initialSearchMode,
+ demoExamples,
+ isAgenticSearchTutorialDismissed,
+}: HomepageProps) => {
+ const [searchMode, setSearchMode] = useState(initialSearchMode);
+ const isAgenticSearchEnabled = languageModels.length > 0;
+
+ const onSearchModeChanged = useCallback(async (newMode: SearchMode) => {
+ setSearchMode(newMode);
+ await setSearchModeCookie(newMode);
+ }, [setSearchMode]);
+
+ useHotkeys("mod+i", (e) => {
+ e.preventDefault();
+ onSearchModeChanged("agentic");
+ }, {
+ enableOnFormTags: true,
+ enableOnContentEditable: true,
+ description: "Switch to agentic search",
+ });
+
+ useHotkeys("mod+p", (e) => {
+ e.preventDefault();
+ onSearchModeChanged("precise");
+ }, {
+ enableOnFormTags: true,
+ enableOnContentEditable: true,
+ description: "Switch to precise search",
+ });
+
+ return (
+
+
+
+
+
+ {searchMode === "precise" ? (
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
diff --git a/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx b/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx
new file mode 100644
index 00000000..d5608940
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { Separator } from "@/components/ui/separator";
+import { SyntaxReferenceGuideHint } from "../syntaxReferenceGuideHint";
+import { RepositorySnapshot } from "./repositorySnapshot";
+import { RepositoryQuery } from "@/lib/types";
+import { useDomain } from "@/hooks/useDomain";
+import Link from "next/link";
+import { SearchBar } from "../searchBar/searchBar";
+import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
+
+interface PreciseSearchProps {
+ initialRepos: RepositoryQuery[];
+ searchModeSelectorProps: SearchModeSelectorProps;
+}
+
+export const PreciseSearch = ({
+ initialRepos,
+ searchModeSelectorProps,
+}: PreciseSearchProps) => {
+ const domain = useDomain();
+
+ return (
+ <>
+
+
+
+
+
+
+
How to search
+
+
+
+ test todo (both test and todo)
+
+
+ test or todo (either test or todo)
+
+
+ {`"exit boot"`} (exact match)
+
+
+ TODO case: yes (case sensitive)
+
+
+
+
+ file: README setup (by filename)
+
+
+ repo: torvalds/linux test (by repo)
+
+
+ lang: typescript (by language)
+
+
+ rev: HEAD (by branch or tag)
+
+
+
+
+ file: {`\\.py$`} {`(files that end in ".py")`}
+
+
+ sym: main {`(symbols named "main")`}
+
+
+ todo -lang:c (negate filter)
+
+
+ content: README (search content only)
+
+
+
+
+
+ >
+ )
+}
+
+const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
+ return (
+
+ {title}
+ {children}
+
+ )
+
+}
+
+const Highlight = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+const QueryExample = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx b/packages/web/src/app/[domain]/components/homepage/repositoryCarousel.tsx
similarity index 83%
rename from packages/web/src/app/[domain]/components/repositoryCarousel.tsx
rename to packages/web/src/app/[domain]/components/homepage/repositoryCarousel.tsx
index 2bbc9024..0a90e1a7 100644
--- a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx
+++ b/packages/web/src/app/[domain]/components/homepage/repositoryCarousel.tsx
@@ -11,6 +11,9 @@ import Image from "next/image";
import { FileIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { RepositoryQuery } from "@/lib/types";
+import { getBrowsePath } from "../../browse/hooks/useBrowseNavigation";
+import Link from "next/link";
+import { useDomain } from "@/hooks/useDomain";
interface RepositoryCarouselProps {
repos: RepositoryQuery[];
@@ -56,7 +59,8 @@ interface RepositoryBadgeProps {
const RepositoryBadge = ({
repo
}: RepositoryBadgeProps) => {
- const { repoIcon, displayName, repoLink } = (() => {
+ const domain = useDomain();
+ const { repoIcon, displayName } = (() => {
const info = getCodeHostInfoForRepo({
codeHostType: repo.codeHostType,
name: repo.repoName,
@@ -72,32 +76,30 @@ const RepositoryBadge = ({
className={`w-4 h-4 ${info.iconClassName}`}
/>,
displayName: info.displayName,
- repoLink: info.repoLink,
}
}
return {
repoIcon: ,
displayName: repo.repoName,
- repoLink: undefined,
}
})();
return (
- {
- if (repoLink !== undefined) {
- window.open(repoLink, "_blank");
- }
- }}
- className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip", {
- "cursor-pointer": repoLink !== undefined,
+
{repoIcon}
{displayName}
-
+
)
}
diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx
similarity index 80%
rename from packages/web/src/app/[domain]/components/repositorySnapshot.tsx
rename to packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx
index eb18c945..d6845fa2 100644
--- a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx
+++ b/packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx
@@ -5,7 +5,7 @@ import { RepositoryCarousel } from "./repositoryCarousel";
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
-import { getRepos } from "@/actions";
+import { getRepos } from "@/app/api/(client)/client";
import { env } from "@/env.mjs";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -16,11 +16,14 @@ import {
import { RepoIndexingStatus } from "@sourcebot/db";
import { SymbolIcon } from "@radix-ui/react-icons";
import { RepositoryQuery } from "@/lib/types";
+import { captureEvent } from "@/hooks/useCaptureEvent";
interface RepositorySnapshotProps {
repos: RepositoryQuery[];
}
+const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;
+
export function RepositorySnapshot({
repos: initialRepos,
}: RepositorySnapshotProps) {
@@ -28,7 +31,7 @@ export function RepositorySnapshot({
const { data: repos, isPending, isError } = useQuery({
queryKey: ['repos', domain],
- queryFn: () => unwrapServiceError(getRepos(domain)),
+ queryFn: () => unwrapServiceError(getRepos()),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
placeholderData: initialRepos,
});
@@ -68,15 +71,32 @@ export function RepositorySnapshot({
return (
- {`Search ${indexedRepos.length} `}
+ {`${indexedRepos.length} `}
{indexedRepos.length > 1 ? 'repositories' : 'repository'}
+ {` indexed`}
-
+
+ {process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
+
+ Interested in using Sourcebot on your code? Check out our{' '}
+ captureEvent('wa_demo_docs_link_pressed', {})}
+ >
+ docs
+
+
+ )}
)
}
diff --git a/packages/web/src/app/[domain]/components/homepage/toolbar.tsx b/packages/web/src/app/[domain]/components/homepage/toolbar.tsx
new file mode 100644
index 00000000..cc9c65e0
--- /dev/null
+++ b/packages/web/src/app/[domain]/components/homepage/toolbar.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+export type SearchMode = "precise" | "agentic";
+
+const PRECISE_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/overview";
+// @tood: point this to the actual docs page
+const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/overview";
+
+export interface SearchModeSelectorProps {
+ searchMode: SearchMode;
+ isAgenticSearchEnabled: boolean;
+ onSearchModeChange: (searchMode: SearchMode) => void;
+ className?: string;
+}
+
+export const SearchModeSelector = ({
+ searchMode,
+ isAgenticSearchEnabled,
+ onSearchModeChange,
+ className,
+}: SearchModeSelectorProps) => {
+ const [focusedSearchMode, setFocusedSearchMode] = useState(searchMode);
+
+ return (
+
+
onSearchModeChange(value as "precise" | "agentic")}
+ >
+
+ {searchMode === "precise" ? (
+
+ ) : (
+
+ )}
+
+ {searchMode === "precise" ? "Code Search" : "Ask"}
+
+
+
+
+
+
+ setFocusedSearchMode("precise")}
+ onFocus={() => setFocusedSearchMode("precise")}
+ >
+
+
+
+
+
+
+
Code Search
+
+
Search for exact matches using regular expressions and filters.
+
+ Docs
+
+
+
+
+
+
+
+
+ setFocusedSearchMode("agentic")}
+ onFocus={() => setFocusedSearchMode("agentic")}
+ className={cn({
+ "cursor-not-allowed": !isAgenticSearchEnabled,
+ })}
+ >
+
+
+
+
+
+
+
+
+
+ {!isAgenticSearchEnabled && (
+
+ )}
+
Ask Sourcebot
+
+ {!isAgenticSearchEnabled && (
+
Language model not configured. See setup instructions.
+ )}
+
+
Use natural language to search, summarize and understand your codebase using a reasoning agent.
+
+ Docs
+
+
+
+
+
+
+
+ )
+}
+
+
diff --git a/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx
index 7791d83f..bb5912ea 100644
--- a/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx
+++ b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx
@@ -153,7 +153,6 @@ export const LightweightCodeHighlighter = memo((prop
)}
-
-
- Search
-
-
+
+ Search
+
-
-
- Repositories
-
-
+
+ Repositories
+
{isAuthenticated && (
<>
{env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && (
-
-
- Agents
-
-
+
+ Agents
+
)}
-
-
- Connections
-
-
+
+ Connections
+
-
-
- Settings
-
-
+
+ Settings
+
>
)}
diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx
index 5118392f..18806937 100644
--- a/packages/web/src/app/[domain]/components/pathHeader.tsx
+++ b/packages/web/src/app/[domain]/components/pathHeader.tsx
@@ -1,11 +1,10 @@
'use client';
-import { getCodeHostInfoForRepo } from "@/lib/utils";
+import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
import { LaptopIcon } from "@radix-ui/react-icons";
-import clsx from "clsx";
import Image from "next/image";
-import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
-import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react";
+import { getBrowsePath } from "../browse/hooks/useBrowseNavigation";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast";
import {
@@ -14,6 +13,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { CopyIconButton } from "./copyIconButton";
+import Link from "next/link";
+import { useDomain } from "@/hooks/useDomain";
interface FileHeaderProps {
path: string;
@@ -30,6 +33,9 @@ interface FileHeaderProps {
},
branchDisplayName?: string;
branchDisplayTitle?: string;
+ isCodeHostIconVisible?: boolean;
+ isFileIconVisible?: boolean;
+ repoNameClassName?: string;
}
interface BreadcrumbSegment {
@@ -49,6 +55,9 @@ export const PathHeader = ({
branchDisplayName,
branchDisplayTitle,
pathType = 'blob',
+ isCodeHostIconVisible = true,
+ isFileIconVisible = true,
+ repoNameClassName,
}: FileHeaderProps) => {
const info = getCodeHostInfoForRepo({
name: repo.name,
@@ -57,30 +66,28 @@ export const PathHeader = ({
webUrl: repo.webUrl,
});
- const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast();
- const [copied, setCopied] = useState(false);
-
const containerRef = useRef(null);
const breadcrumbsRef = useRef(null);
const [visibleSegmentCount, setVisibleSegmentCount] = useState(null);
-
+ const domain = useDomain();
+
// Create breadcrumb segments from file path
const breadcrumbSegments = useMemo(() => {
const pathParts = path.split('/').filter(Boolean);
const segments: BreadcrumbSegment[] = [];
-
+
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLastSegment = index === pathParts.length - 1;
-
+
// Calculate highlight range for this segment if it exists
let segmentHighlight: { from: number; to: number } | undefined;
if (pathHighlightRange) {
const segmentStart = path.indexOf(part, currentPath.length - part.length);
const segmentEnd = segmentStart + part.length;
-
+
// Check if highlight overlaps with this segment
if (pathHighlightRange.from < segmentEnd && pathHighlightRange.to > segmentStart) {
segmentHighlight = {
@@ -89,7 +96,7 @@ export const PathHeader = ({
};
}
}
-
+
segments.push({
name: part,
fullPath: currentPath,
@@ -97,7 +104,7 @@ export const PathHeader = ({
highlightRange: segmentHighlight
});
});
-
+
return segments;
}, [path, pathHighlightRange]);
@@ -105,10 +112,10 @@ export const PathHeader = ({
useEffect(() => {
const measureSegments = () => {
if (!containerRef.current || !breadcrumbsRef.current) return;
-
+
const containerWidth = containerRef.current.offsetWidth;
const availableWidth = containerWidth - 175; // Reserve space for copy button and padding
-
+
// Create a temporary element to measure segment widths
const tempElement = document.createElement('div');
tempElement.style.position = 'absolute';
@@ -116,17 +123,17 @@ export const PathHeader = ({
tempElement.style.whiteSpace = 'nowrap';
tempElement.className = 'font-mono text-sm';
document.body.appendChild(tempElement);
-
+
let totalWidth = 0;
let visibleCount = breadcrumbSegments.length;
-
+
// Start from the end (most important segments) and work backwards
for (let i = breadcrumbSegments.length - 1; i >= 0; i--) {
const segment = breadcrumbSegments[i];
tempElement.textContent = segment.name;
const segmentWidth = tempElement.offsetWidth;
const separatorWidth = i < breadcrumbSegments.length - 1 ? 16 : 0; // ChevronRight width
-
+
if (totalWidth + segmentWidth + separatorWidth > availableWidth && i > 0) {
// If adding this segment would overflow and it's not the last segment
visibleCount = breadcrumbSegments.length - i;
@@ -136,21 +143,21 @@ export const PathHeader = ({
}
break;
}
-
+
totalWidth += segmentWidth + separatorWidth;
}
-
+
document.body.removeChild(tempElement);
setVisibleSegmentCount(visibleCount);
};
measureSegments();
-
+
const resizeObserver = new ResizeObserver(measureSegments);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
-
+
return () => resizeObserver.disconnect();
}, [breadcrumbSegments]);
@@ -170,21 +177,10 @@ export const PathHeader = ({
const onCopyPath = useCallback(() => {
navigator.clipboard.writeText(path);
- setCopied(true);
toast({ description: "✅ Copied to clipboard" });
- setTimeout(() => setCopied(false), 1500);
+ return true;
}, [path, toast]);
- const onBreadcrumbClick = useCallback((segment: BreadcrumbSegment) => {
- navigateToPath({
- repoName: repo.name,
- path: segment.fullPath,
- pathType: segment.isLastSegment ? pathType : 'tree',
- revisionName: branchDisplayName,
- });
- }, [repo.name, branchDisplayName, navigateToPath, pathType]);
-
-
const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => {
if (!segment.highlightRange) {
return segment.name;
@@ -204,28 +200,34 @@ export const PathHeader = ({
return (
- {info?.icon ? (
-
-
-
- ) : (
-
+ {isCodeHostIconVisible && (
+ <>
+ {info?.icon ? (
+
+
+
+ ) : (
+
+ )}
+ >
)}
-
navigateToPath({
+
+
{info?.displayName}
-
+
{branchDisplayName && (
{hiddenSegments.map((segment) => (
- onBreadcrumbClick(segment)}
- className="font-mono text-sm cursor-pointer"
>
- {renderSegmentWithHighlight(segment)}
-
+
+ {renderSegmentWithHighlight(segment)}
+
+
))}
@@ -269,32 +279,33 @@ export const PathHeader = ({
)}
{visibleSegments.map((segment, index) => (
-
+ )}
+ onBreadcrumbClick(segment)}
+ href={getBrowsePath({
+ repoName: repo.name,
+ path: segment.fullPath,
+ pathType: segment.isLastSegment ? pathType : 'tree',
+ revisionName: branchDisplayName,
+ domain,
+ })}
>
{renderSegmentWithHighlight(segment)}
-
+
{index < visibleSegments.length - 1 && (
)}
))}
-
- {copied ? (
-
- ) : (
-
- )}
-
+
)
diff --git a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx
index 7d79d7bc..f9e0d8cb 100644
--- a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx
+++ b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx
@@ -1,6 +1,5 @@
"use client";
-import { getRepos } from "@/actions";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
@@ -10,14 +9,15 @@ import { RepoIndexingStatus } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import { Loader2Icon } from "lucide-react";
import Link from "next/link";
+import { getRepos } from "@/app/api/(client)/client";
export const ProgressNavIndicator = () => {
const domain = useDomain();
const captureEvent = useCaptureEvent();
const { data: inProgressRepos, isPending, isError } = useQuery({
- queryKey: ['repos', domain],
- queryFn: () => unwrapServiceError(getRepos(domain)),
+ queryKey: ['repos'],
+ queryFn: () => unwrapServiceError(getRepos()),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
@@ -50,17 +50,13 @@ export const ProgressNavIndicator = () => {
{
inProgressRepos.slice(0, 10)
- .filter(item => item.linkedConnections.length > 0) // edge case: don't show repos that are orphaned and awaiting gc.
.map(item => (
- // Link to the first connection for the repo
-
captureEvent('wa_progress_nav_job_pressed', {})}>
-
- {item.repoName}
-
-
+
+ {item.repoName}
+
)
)}
{inProgressRepos.length > 10 && (
diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
index d6fec7d8..6dbd47cd 100644
--- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
+++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
@@ -78,8 +78,8 @@ const searchBarContainerVariants = cva(
{
variants: {
size: {
- default: "h-10",
- sm: "h-8"
+ default: "min-h-10",
+ sm: "min-h-8"
}
},
defaultVariants: {
@@ -168,6 +168,7 @@ export const SearchBar = ({
keymap.of(searchBarKeymap),
history(),
zoekt(),
+ EditorView.lineWrapping,
EditorView.updateListener.of(update => {
if (update.selectionSet) {
const selection = update.state.selection.main;
@@ -259,7 +260,7 @@ export const SearchBar = ({
/>
{
diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx
index 6b208c0e..eb167640 100644
--- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx
+++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx
@@ -334,6 +334,7 @@ const SearchSuggestionsBox = forwardRef(({
}
if (e.key === 'ArrowUp') {
+ e.preventDefault();
e.stopPropagation();
setHighlightedSuggestionIndex((curIndex) => {
return curIndex <= 0 ? suggestions.length - 1 : curIndex - 1;
@@ -341,6 +342,7 @@ const SearchSuggestionsBox = forwardRef(({
}
if (e.key === 'ArrowDown') {
+ e.preventDefault();
e.stopPropagation();
setHighlightedSuggestionIndex((curIndex) => {
return curIndex >= suggestions.length - 1 ? 0 : curIndex + 1;
diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts
index 04c2514d..a1f9c453 100644
--- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts
+++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts
@@ -19,7 +19,7 @@ import {
VscSymbolVariable
} from "react-icons/vsc";
import { useSearchHistory } from "@/hooks/useSearchHistory";
-import { getDisplayTime, isServiceError } from "@/lib/utils";
+import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
@@ -37,12 +37,12 @@ export const useSuggestionsData = ({
}: Props) => {
const domain = useDomain();
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
- queryKey: ["repoSuggestions", domain],
- queryFn: () => getRepos(domain),
+ queryKey: ["repoSuggestions"],
+ queryFn: () => unwrapServiceError(getRepos()),
select: (data): Suggestion[] => {
- return data.repos
+ return data
.map(r => ({
- value: r.name,
+ value: r.repoName,
}));
},
enabled: suggestionMode === "repo",
diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx
index 4f3350f6..377d0ef7 100644
--- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx
+++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx
@@ -46,7 +46,7 @@ export const SettingsDropdown = ({
const { theme: _theme, setTheme } = useTheme();
const [keymapType, setKeymapType] = useKeymapType();
- const { data: session, update } = useSession();
+ const { data: session } = useSession();
const domain = useDomain();
const theme = useMemo(() => {
@@ -67,14 +67,7 @@ export const SettingsDropdown = ({
}, [theme]);
return (
- // Was hitting a bug with invite code login where the first time the user signs in, the settingsDropdown doesn't have a valid session. To fix this
- // we can simply update the session everytime the settingsDropdown is opened. This isn't a super frequent operation and updating the session is low cost,
- // so this is a simple solution to the problem.
- {
- if (isOpen) {
- update();
- }
- }}>
+
diff --git a/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx b/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx
index 11fcf28c..52ec7aea 100644
--- a/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx
+++ b/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx
@@ -10,11 +10,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import clsx from "clsx";
import Link from "next/link";
import { useCallback, useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useSyntaxGuide } from "./syntaxGuideProvider";
+import { CodeSnippet } from "@/app/components/codeSnippet";
const LINGUIST_LINK = "https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml";
const CTAGS_LINK = "https://ctags.io/";
@@ -66,7 +66,7 @@ export const SyntaxReferenceGuide = () => {
Syntax Reference Guide
- Queries consist of space-separated regular expressions. Wrapping expressions in {`""`} combines them. By default, a file must have at least one match for each expression to be included.
+ Queries consist of space-seperated regular expressions. Wrapping expressions in {`""`} combines them. By default, a file must have at least one match for each expression to be included.
@@ -78,23 +78,23 @@ export const SyntaxReferenceGuide = () => {
- foo
- Match files with regex /foo/
+ foo
+ Match files with regex /foo/
- foo bar
- Match files with regex /foo/ and /bar/
+ foo bar
+ Match files with regex /foo/ and /bar/
- {`"foo bar"`}
- Match files with regex /foo bar/
+ {`"foo bar"`}
+ Match files with regex /foo bar/
- {`Multiple expressions can be or'd together with `}or, negated with -, or grouped with ().
+ {`Multiple expressions can be or'd together with `}or , negated with - , or grouped with () .
@@ -105,23 +105,23 @@ export const SyntaxReferenceGuide = () => {
- foo or bar
- Match files with regex /foo/ or /bar/
+ foo or bar
+ Match files with regex /foo/ or /bar/
- foo -bar
- Match files with regex /foo/ but not /bar/
+ foo -bar
+ Match files with regex /foo/ but not /bar/
- foo (bar or baz)
- Match files with regex /foo/ and either /bar/ or /baz/
+ foo (bar or baz)
+ Match files with regex /foo/ and either /bar/ or /baz/
- Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the - prefix.
+ Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the - prefix.
@@ -134,87 +134,87 @@ export const SyntaxReferenceGuide = () => {
- file:
+ file:
Filter results from filepaths that match the regex. By default all files are searched.
-
file: README
-
-
+
file: {`"my file"`}
-
-
+
-file: test\.ts$
-
+
- repo:
+ repo:
Filter results from repos that match the regex. By default all repos are searched.
-
repo: linux
-
-
+
-repo: ^web/.*
-
+
- rev:
+ rev:
Filter results from a specific branch or tag. By default only the default branch is searched.
-
rev: beta
-
+
- lang:
+ lang:
Filter results by language (as defined by linguist). By default all languages are searched.
-
lang: TypeScript
-
-
+
-lang: YAML
-
+
- sym:
+ sym:
Match symbol definitions created by universal ctags at index time.
-
sym: \bmain\b
-
+
@@ -225,17 +225,6 @@ export const SyntaxReferenceGuide = () => {
)
}
-const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
- return (
-
- {children}
-
- )
-}
-
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
diff --git a/packages/web/src/app/[domain]/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx
index 351eb60b..6661ae42 100644
--- a/packages/web/src/app/[domain]/components/topBar.tsx
+++ b/packages/web/src/app/[domain]/components/topBar.tsx
@@ -2,45 +2,44 @@ import Link from "next/link";
import Image from "next/image";
import logoLight from "@/public/sb_logo_light.png";
import logoDark from "@/public/sb_logo_dark.png";
-import { SearchBar } from "./searchBar";
import { SettingsDropdown } from "./settingsDropdown";
+import { Separator } from "@/components/ui/separator";
interface TopBarProps {
- defaultSearchQuery?: string;
domain: string;
+ children?: React.ReactNode;
}
export const TopBar = ({
- defaultSearchQuery,
domain,
+ children,
}: TopBarProps) => {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {children}
+
+
-
+
)
}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx
deleted file mode 100644
index 0da3eb98..00000000
--- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-'use client';
-
-import { Button } from "@/components/ui/button";
-import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
-import { Loader2 } from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import ConfigEditor, { isConfigValidJson, onQuickAction, QuickAction } from "../../../components/configEditor";
-import { createZodConnectionConfigValidator } from "../../utils";
-import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
-import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
-import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
-import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions, bitbucketCloudQuickActions, bitbucketDataCenterQuickActions } from "../../quickActions";
-import { Schema } from "ajv";
-import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
-import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
-import { checkIfSecretExists, updateConnectionConfigAndScheduleSync } from "@/actions";
-import { useToast } from "@/components/hooks/use-toast";
-import { isServiceError, CodeHostType, isAuthSupportedForCodeHost } from "@/lib/utils";
-import { useRouter } from "next/navigation";
-import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
-import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
-import { useDomain } from "@/hooks/useDomain";
-import { SecretCombobox } from "@/app/[domain]/components/connectionCreationForms/secretCombobox";
-import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
-import strings from "@/lib/strings";
-import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
-import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
-
-interface ConfigSettingProps {
- connectionId: number;
- config: string;
- type: CodeHostType;
- disabled?: boolean;
-}
-
-export const ConfigSetting = (props: ConfigSettingProps) => {
- const { type } = props;
-
- if (type === 'github') {
- return
- {...props}
- type="github"
- quickActions={githubQuickActions}
- schema={githubSchema}
- />;
- }
-
- if (type === 'gitlab') {
- return
- {...props}
- type="gitlab"
- quickActions={gitlabQuickActions}
- schema={gitlabSchema}
- />;
- }
-
- if (type === 'bitbucket-cloud') {
- return
- {...props}
- type="bitbucket-cloud"
- quickActions={bitbucketCloudQuickActions}
- schema={bitbucketSchema}
- />;
- }
-
- if (type === 'bitbucket-server') {
- return
- {...props}
- type="bitbucket-server"
- quickActions={bitbucketDataCenterQuickActions}
- schema={bitbucketSchema}
- />;
- }
-
- if (type === 'gitea') {
- return
- {...props}
- type="gitea"
- quickActions={giteaQuickActions}
- schema={giteaSchema}
- />;
- }
-
- if (type === 'gerrit') {
- return
- {...props}
- type="gerrit"
- quickActions={gerritQuickActions}
- schema={gerritSchema}
- />;
- }
-
- return null;
-}
-
-
-function ConfigSettingInternal({
- connectionId,
- config,
- quickActions,
- schema,
- type,
- disabled,
-}: ConfigSettingProps & {
- quickActions?: QuickAction[],
- schema: Schema,
- type: CodeHostType,
- disabled?: boolean,
-}) {
- const { toast } = useToast();
- const router = useRouter();
- const domain = useDomain();
- const editorRef = useRef(null);
- const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
-
- const formSchema = useMemo(() => {
- return z.object({
- config: createZodConnectionConfigValidator(schema),
- secretKey: z.string().optional().refine(async (secretKey) => {
- if (!secretKey) {
- return true;
- }
-
- return checkIfSecretExists(secretKey, domain);
- }, { message: "Secret not found" })
- });
- }, [schema, domain]);
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- config,
- },
- });
-
- const [isLoading, setIsLoading] = useState(false);
- const onSubmit = useCallback((data: z.infer) => {
- setIsLoading(true);
- updateConnectionConfigAndScheduleSync(connectionId, data.config, domain)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to update connection. Reason: ${response.message}`
- });
- } else {
- toast({
- description: `✅ Connection config updated successfully.`
- });
- router.push(`?tab=overview`);
- router.refresh();
- }
- })
- .finally(() => {
- setIsLoading(false);
- })
- }, [connectionId, domain, router, toast]);
-
- const onConfigChange = useCallback((value: string) => {
- form.setValue("config", value);
- const isValid = isConfigValidJson(value);
- setIsSecretsDisabled(!isValid);
- if (isValid) {
- const configJson = JSON.parse(value);
- if (configJson.token?.secret !== undefined) {
- form.setValue("secretKey", configJson.token.secret);
- } else {
- form.setValue("secretKey", undefined);
- }
- }
- }, [form]);
-
- useEffect(() => {
- onConfigChange(config);
- }, [config, onConfigChange]);
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx
index a1e49637..962b04cb 100644
--- a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx
+++ b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx
@@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
queryKey: ['repos', domain, connectionId],
queryFn: async () => {
- const repos = await unwrapServiceError(getRepos(domain, { connectionId }));
+ const repos = await unwrapServiceError(getRepos({ connectionId }));
return repos.sort((a, b) => {
const priorityA = getPriority(a.repoIndexingStatus);
const priorityB = getPriority(b.repoIndexingStatus);
@@ -98,7 +98,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
}
setIsRetryAllFailedReposLoading(true);
- flagReposForIndex(failedRepos.map((repo) => repo.repoId), domain)
+ flagReposForIndex(failedRepos.map((repo) => repo.repoId))
.then((response) => {
if (isServiceError(response)) {
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
@@ -116,7 +116,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
.finally(() => {
setIsRetryAllFailedReposLoading(false);
});
- }, [captureEvent, domain, failedRepos, refetchRepos, toast]);
+ }, [captureEvent, failedRepos, refetchRepos, toast]);
const filteredRepos = useMemo(() => {
if (isServiceError(unfilteredRepos)) {
diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx
index e2f8c55c..fd491376 100644
--- a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx
+++ b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx
@@ -70,7 +70,7 @@ export const RepoListItem = ({
{status === RepoIndexingStatus.FAILED && (
-
+
)}
{
+export const RetryRepoIndexButton = ({ repoId }: RetryRepoIndexButtonProps) => {
const captureEvent = useCaptureEvent();
return (
@@ -21,7 +20,7 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
size="sm"
className="ml-2"
onClick={async () => {
- const result = await flagReposForIndex([repoId], domain);
+ const result = await flagReposForIndex([repoId]);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag repository for indexing.`,
diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx
index e59dd3e0..0e0a91d2 100644
--- a/packages/web/src/app/[domain]/connections/[id]/page.tsx
+++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx
@@ -7,51 +7,29 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
-import { TabSwitcher } from "@/components/ui/tab-switcher"
-import { Tabs, TabsContent } from "@/components/ui/tabs"
import { ConnectionIcon } from "../components/connectionIcon"
import { Header } from "../../components/header"
-import { ConfigSetting } from "./components/configSetting"
-import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
-import { DisplayNameSetting } from "./components/displayNameSetting"
import { RepoList } from "./components/repoList"
import { getConnectionByDomain } from "@/data/connection"
import { Overview } from "./components/overview"
-import { getOrgMembership } from "@/actions"
-import { isServiceError } from "@/lib/utils"
-import { notFound } from "next/navigation"
-import { OrgRole } from "@sourcebot/db"
-import { CodeHostType } from "@/lib/utils"
-import { env } from "@/env.mjs"
interface ConnectionManagementPageProps {
- params: {
+ params: Promise<{
domain: string
id: string
- },
- searchParams: {
- tab: string
- }
+ }>,
}
-export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
+export default async function ConnectionManagementPage(props: ConnectionManagementPageProps) {
+ const params = await props.params;
const connection = await getConnectionByDomain(Number(params.id), params.domain);
if (!connection) {
return
}
- const membership = await getOrgMembership(params.domain);
- if (isServiceError(membership)) {
- return notFound();
- }
-
- const isOwner = membership.role === OrgRole.OWNER;
- const isDisabled = !isOwner || env.CONFIG_PATH !== undefined;
- const currentTab = searchParams.tab || "overview";
-
return (
-
-
+
);
}
diff --git a/packages/web/src/app/[domain]/connections/quickActions.tsx b/packages/web/src/app/[domain]/connections/quickActions.tsx
index af0b4f05..5f8008b0 100644
--- a/packages/web/src/app/[domain]/connections/quickActions.tsx
+++ b/packages/web/src/app/[domain]/connections/quickActions.tsx
@@ -4,18 +4,7 @@ import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
import { QuickAction } from "../components/configEditor";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
-import { cn } from "@/lib/utils";
-
-const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
- return (
-
- {children}
-
- )
-}
+import { CodeSnippet } from "@/app/components/codeSnippet";
export const githubQuickActions: QuickAction[] = [
{
@@ -30,7 +19,7 @@ export const githubQuickActions: QuickAction[] = [
selectionText: "/",
description: (
-
Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any).
+
Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any).
Examples:
{[
@@ -38,7 +27,7 @@ export const githubQuickActions: QuickAction[] = [
"vercel/next.js",
"torvalds/linux"
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -56,7 +45,7 @@ export const githubQuickActions: QuickAction[] = [
selectionText: "",
description: (
-
Add an organization to sync with. All repositories in the organization visible to the provided token (if any) will be synced.
+
Add an organization to sync with. All repositories in the organization visible to the provided token (if any) will be synced.
Examples:
{[
@@ -64,7 +53,7 @@ export const githubQuickActions: QuickAction[] = [
"sourcebot",
"vercel"
].map((org) => (
- {org}
+ {org}
))}
@@ -82,7 +71,7 @@ export const githubQuickActions: QuickAction[] = [
selectionText: "",
description: (
-
Add a user to sync with. All repositories that the user owns visible to the provided token (if any) will be synced.
+
Add a user to sync with. All repositories that the user owns visible to the provided token (if any) will be synced.
Examples:
{[
@@ -90,7 +79,7 @@ export const githubQuickActions: QuickAction[] = [
"torvalds",
"octocat"
].map((org) => (
- {org}
+ {org}
))}
@@ -103,7 +92,7 @@ export const githubQuickActions: QuickAction[] = [
}),
name: "Set url to GitHub instance",
selectionText: "https://github.example.com",
- description: Set a custom GitHub host. Defaults to https://github.com.
+ description: Set a custom GitHub host. Defaults to https://github.com .
},
{
fn: (previous: GithubConnectionConfig) => ({
@@ -127,7 +116,7 @@ export const githubQuickActions: QuickAction[] = [
"my-org/docs*",
"my-org/test*"
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -155,7 +144,7 @@ export const githubQuickActions: QuickAction[] = [
"docs",
"ci"
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -180,7 +169,7 @@ export const githubQuickActions: QuickAction[] = [
"docs",
"ci"
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -223,14 +212,14 @@ export const gitlabQuickActions: QuickAction[] = [
selectionText: "",
description: (
-
Add a individual project to sync with. Ensure the project is visible to the provided token (if any).
+
Add a individual project to sync with. Ensure the project is visible to the provided token (if any).
Examples:
{[
"gitlab-org/gitlab",
"corp/team-project",
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -248,14 +237,14 @@ export const gitlabQuickActions: QuickAction[] = [
selectionText: "",
description: (
-
Add a user to sync with. All projects that the user owns visible to the provided token (if any) will be synced.
+
Add a user to sync with. All projects that the user owns visible to the provided token (if any) will be synced.
Examples:
{[
"jane-doe",
"torvalds"
].map((org) => (
- {org}
+ {org}
))}
@@ -273,14 +262,14 @@ export const gitlabQuickActions: QuickAction[] = [
selectionText: "",
description: (
-
Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided token (if any) will be synced.
+
Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided token (if any) will be synced.
Examples:
{[
"my-group",
"path/to/subgroup"
].map((org) => (
- {org}
+ {org}
))}
@@ -293,7 +282,7 @@ export const gitlabQuickActions: QuickAction[] = [
}),
name: "Set url to GitLab instance",
selectionText: "https://gitlab.example.com",
- description: Set a custom GitLab host. Defaults to https://gitlab.com.
+ description: Set a custom GitLab host. Defaults to https://gitlab.com .
},
{
fn: (previous: GitlabConnectionConfig) => ({
@@ -301,7 +290,7 @@ export const gitlabQuickActions: QuickAction[] = [
all: true,
}),
name: "Sync all projects",
- description: Sync all projects visible to the provided token (if any). Only available when using a self-hosted GitLab instance.
+ description: Sync all projects visible to the provided token (if any). Only available when using a self-hosted GitLab instance.
},
{
fn: (previous: GitlabConnectionConfig) => ({
@@ -325,7 +314,7 @@ export const gitlabQuickActions: QuickAction[] = [
"docs/**",
"**/tests/**",
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -403,7 +392,7 @@ export const bitbucketCloudQuickActions: QuickAction[
selectionText: "username",
description: (
- Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication.
+ Username to use for authentication. This is only required if you're using an App Password (stored in token ) for authentication.
)
},
@@ -419,7 +408,7 @@ export const bitbucketCloudQuickActions: QuickAction[
selectionText: "myWorkspace",
description: (
- Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any).
+ Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any).
)
},
@@ -435,7 +424,7 @@ export const bitbucketCloudQuickActions: QuickAction[
selectionText: "myWorkspace/myRepo",
description: (
- Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any).
+ Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any).
)
},
@@ -451,7 +440,7 @@ export const bitbucketCloudQuickActions: QuickAction[
selectionText: "myProject",
description: (
- Add a project to sync with. Ensure the project is visible to the provided token (if any).
+ Add a project to sync with. Ensure the project is visible to the provided token (if any).
)
},
@@ -506,14 +495,14 @@ export const bitbucketDataCenterQuickActions: QuickAction
- Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any).
+ Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any).
Examples:
{[
"PROJ/repo-name",
"MYPROJ/api"
].map((repo) => (
- {repo}
+ {repo}
))}
@@ -531,7 +520,7 @@ export const bitbucketDataCenterQuickActions: QuickAction
- Add a project to sync with. Ensure the project is visible to the provided token (if any).
+ Add a project to sync with. Ensure the project is visible to the provided token (if any).
)
},
@@ -554,7 +543,7 @@ export const bitbucketDataCenterQuickActions: QuickAction (
- {repo}
+ {repo}
))}
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx
index 6a3e34da..06dca7d8 100644
--- a/packages/web/src/app/[domain]/layout.tsx
+++ b/packages/web/src/app/[domain]/layout.tsx
@@ -21,16 +21,24 @@ import { GcpIapAuth } from "./components/gcpIapAuth";
import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
+import { GitHubStarToast } from "./components/githubStarToast";
interface LayoutProps {
children: React.ReactNode,
- params: { domain: string }
+ params: Promise<{ domain: string }>
}
-export default async function Layout({
- children,
- params: { domain },
-}: LayoutProps) {
+export default async function Layout(props: LayoutProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
+ const {
+ children
+ } = props;
+
const org = await getOrgFromDomain(domain);
if (!org) {
@@ -38,8 +46,19 @@ export default async function Layout({
}
const session = await auth();
- const anonymousAccessEnabled = hasEntitlement("anonymous-access") && await getAnonymousAccessStatus(domain);
-
+ const anonymousAccessEnabled = await (async () => {
+ if (!hasEntitlement("anonymous-access")) {
+ return false;
+ }
+
+ const status = await getAnonymousAccessStatus(domain);
+ if (isServiceError(status)) {
+ return false;
+ }
+
+ return status;
+ })();
+
// If the user is authenticated, we must check if they're a member of the org
if (session) {
const membership = await prisma.userToOrg.findUnique({
@@ -94,7 +113,8 @@ export default async function Layout({
}
}
- if (!org.isOnboarded) {
+ // If the org is not onboarded, and GCP IAP is not enabled, show the onboarding page
+ if (!org.isOnboarded && !(env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE)) {
return (
{children}
@@ -133,6 +153,7 @@ export default async function Layout({
{children}
+
)
}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx
index 032d3433..7fd66f43 100644
--- a/packages/web/src/app/[domain]/page.tsx
+++ b/packages/web/src/app/[domain]/page.tsx
@@ -1,24 +1,83 @@
-import { NavigationMenu } from "./components/navigationMenu";
-import { SearchBar } from "./components/searchBar";
-import { Separator } from "@/components/ui/separator";
-import { UpgradeToast } from "./components/upgradeToast";
-import Link from "next/link";
-import { getOrgFromDomain } from "@/data/org";
-import { PageNotFound } from "./components/pageNotFound";
+import { getRepos, getSearchContexts } from "@/actions";
import { Footer } from "@/app/components/footer";
-import { SourcebotLogo } from "../components/sourcebotLogo";
-import { RepositorySnapshot } from "./components/repositorySnapshot";
-import { SyntaxReferenceGuideHint } from "./components/syntaxReferenceGuideHint";
-import { getRepos } from "@/actions";
-import { isServiceError } from "@/lib/utils";
+import { getOrgFromDomain } from "@/data/org";
+import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
+import { isServiceError, measure } from "@/lib/utils";
+import { Homepage } from "./components/homepage";
+import { NavigationMenu } from "./components/navigationMenu";
+import { PageNotFound } from "./components/pageNotFound";
+import { UpgradeToast } from "./components/upgradeToast";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { auth } from "@/auth";
+import { cookies } from "next/headers";
+import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME } from "@/lib/constants";
+import { env } from "@/env.mjs";
+import { loadJsonFile } from "@sourcebot/shared";
+import { DemoExamples, demoExamplesSchema } from "@/types";
+import { createLogger } from "@sourcebot/logger";
-export default async function Home({ params: { domain } }: { params: { domain: string } }) {
- const org = await getOrgFromDomain(domain);
+const logger = createLogger('web-homepage');
+
+export default async function Home(props: { params: Promise<{ domain: string }> }) {
+ logger.debug('Starting homepage load...');
+ const { data: HomePage, durationMs } = await measure(() => HomeInternal(props), 'HomeInternal', /* outputLog = */ false);
+ logger.debug(`Homepage load completed in ${durationMs}ms.`);
+
+ return HomePage;
+}
+
+const HomeInternal = async (props: { params: Promise<{ domain: string }> }) => {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
+
+ const org = (await measure(() => getOrgFromDomain(domain), 'getOrgFromDomain')).data;
if (!org) {
return
}
- const repos = await getRepos(domain);
+ const session = (await measure(() => auth(), 'auth')).data;
+ const models = (await measure(() => getConfiguredLanguageModelsInfo(), 'getConfiguredLanguageModelsInfo')).data;
+ const repos = (await measure(() => getRepos(), 'getRepos')).data;
+ const searchContexts = (await measure(() => getSearchContexts(domain), 'getSearchContexts')).data;
+ const chatHistory = session ? (await measure(() => getUserChatHistory(domain), 'getUserChatHistory')).data : [];
+
+ if (isServiceError(repos)) {
+ throw new ServiceErrorException(repos);
+ }
+
+ if (isServiceError(searchContexts)) {
+ throw new ServiceErrorException(searchContexts);
+ }
+
+ if (isServiceError(chatHistory)) {
+ throw new ServiceErrorException(chatHistory);
+ }
+
+ const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
+
+ // Read search mode from cookie, defaulting to agentic if not set
+ // (assuming a language model is configured).
+ const cookieStore = (await measure(() => cookies(), 'cookies')).data;
+ const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME);
+ const initialSearchMode = (
+ searchModeCookie?.value === "agentic" ||
+ searchModeCookie?.value === "precise"
+ ) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
+
+ const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
+
+ const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
+ try {
+ return (await measure(() => loadJsonFile(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema), 'loadExamplesJsonFile')).data;
+ } catch (error) {
+ console.error('Failed to load demo examples:', error);
+ return undefined;
+ }
+ })() : undefined;
return (
@@ -26,123 +85,17 @@ export default async function Home({ params: { domain } }: { params: { domain: s
domain={domain}
/>
-
-
-
-
-
-
-
-
-
-
-
How to search
-
-
-
- test todo (both test and todo)
-
-
- test or todo (either test or todo)
-
-
- {`"exit boot"`} (exact match)
-
-
- TODO case: yes (case sensitive)
-
-
-
-
- file: README setup (by filename)
-
-
- repo: torvalds/linux test (by repo)
-
-
- lang: typescript (by language)
-
-
- rev: HEAD (by branch or tag)
-
-
-
-
- file: {`\\.py$`} {`(files that end in ".py")`}
-
-
- sym: main {`(symbols named "main")`}
-
-
- todo -lang:c (negate filter)
-
-
- content: README (search content only)
-
-
-
-
-
-
+
+
)
-}
-
-const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
- return (
-
- {title}
- {children}
-
- )
-
-}
-
-const Highlight = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- )
-}
-
-const QueryExample = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- )
-}
-
-const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- )
-}
-
-const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
- return (
-
- {children}
-
- )
-}
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/repos/addRepoButton.tsx b/packages/web/src/app/[domain]/repos/addRepoButton.tsx
deleted file mode 100644
index 739f4703..00000000
--- a/packages/web/src/app/[domain]/repos/addRepoButton.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client"
-
-import { Button } from "@/components/ui/button"
-import { PlusCircle } from "lucide-react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogClose,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { useState } from "react"
-import { ConnectionList } from "../connections/components/connectionList"
-import { useDomain } from "@/hooks/useDomain"
-import Link from "next/link";
-import { useSession } from "next-auth/react"
-
-export function AddRepoButton() {
- const [isOpen, setIsOpen] = useState(false)
- const domain = useDomain()
- const { data: session } = useSession();
-
- return (
- <>
- {session?.user && (
- <>
- setIsOpen(true)}
- variant="ghost"
- size="icon"
- className="h-8 w-8 ml-2 text-muted-foreground hover:text-foreground transition-colors"
- >
-
-
-
-
-
-
- Add a New Repository
-
- Repositories are added to Sourcebot using connections . To add a new repo, add it to an existing connection or create a new one.
-
-
-
-
-
-
-
- Add new connection
-
-
- Close
-
-
-
-
- >
- )
- }
- >
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx
index 4d985cba..ca37a9b4 100644
--- a/packages/web/src/app/[domain]/repos/columns.tsx
+++ b/packages/web/src/app/[domain]/repos/columns.tsx
@@ -2,26 +2,22 @@
import { Button } from "@/components/ui/button"
import type { ColumnDef } from "@tanstack/react-table"
-import { ArrowUpDown, ExternalLink, Clock, Loader2, CheckCircle2, XCircle, Trash2, Check, ListFilter } from "lucide-react"
+import { ArrowUpDown, Clock, Loader2, CheckCircle2, XCircle, Trash2, Check, ListFilter } from "lucide-react"
import Image from "next/image"
-import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn, getRepoImageSrc } from "@/lib/utils"
import { RepoIndexingStatus } from "@sourcebot/db";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-import { AddRepoButton } from "./addRepoButton"
+import Link from "next/link"
+import { getBrowsePath } from "../browse/hooks/useBrowseNavigation"
export type RepositoryColumnInfo = {
repoId: number
- name: string
+ repoName: string;
+ repoDisplayName: string
imageUrl?: string
- connections: {
- id: number
- name: string
- }[]
repoIndexingStatus: RepoIndexingStatus
lastIndexed: string
- url: string
}
const statusLabels = {
@@ -96,81 +92,43 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
export const columns = (domain: string): ColumnDef[] => [
{
- accessorKey: "name",
- header: () => (
-
- ),
- cell: ({ row }) => {
- const repo = row.original
- const url = repo.url
- const isRemoteRepo = url.length > 0
-
+ accessorKey: "repoDisplayName",
+ header: 'Repository',
+ cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => {
return (
- {repo.imageUrl ? (
+ {imageUrl ? (
) : (
- {repo.name.charAt(0)}
+ {repoDisplayName.charAt(0)}
)}
- {
- if (isRemoteRepo) {
- window.open(url, "_blank")
- }
- }}
+
- {repo.name.length > 40 ? `${repo.name.slice(0, 40)}...` : repo.name}
-
- {isRemoteRepo && }
+ {repoDisplayName.length > 40 ? `${repoDisplayName.slice(0, 40)}...` : repoDisplayName}
+
)
},
},
- {
- accessorKey: "connections",
- header: () => Connections
,
- cell: ({ row }) => {
- const connections = row.original.connections
-
- if (!connections || connections.length === 0) {
- return —
- }
-
- return (
-
- {connections.map((connection) => (
- {
- window.location.href = `/${domain}/connections/${connection.id}`
- }}
- >
- {connection.name}
-
-
- ))}
-
- )
- },
- },
{
accessorKey: "repoIndexingStatus",
header: ({ column }) => {
diff --git a/packages/web/src/app/[domain]/repos/components/addRepositoryDialog.tsx b/packages/web/src/app/[domain]/repos/components/addRepositoryDialog.tsx
new file mode 100644
index 00000000..45a8b6c6
--- /dev/null
+++ b/packages/web/src/app/[domain]/repos/components/addRepositoryDialog.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { experimental_addGithubRepositoryByUrl } from "@/actions";
+import { isServiceError } from "@/lib/utils";
+import { useToast } from "@/components/hooks/use-toast";
+import { useRouter } from "next/navigation";
+
+interface AddRepositoryDialogProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+// Validation schema for repository URLs
+const formSchema = z.object({
+ repositoryUrl: z.string()
+ .min(1, "Repository URL is required")
+ .refine((url) => {
+ // Allow various GitHub URL formats:
+ // - https://github.com/owner/repo
+ // - github.com/owner/repo
+ // - owner/repo
+ const patterns = [
+ /^https?:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/?$/,
+ /^github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/?$/,
+ /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/
+ ];
+ return patterns.some(pattern => pattern.test(url.trim()));
+ }, "Please enter a valid GitHub repository URL (e.g., owner/repo or https://github.com/owner/repo)"),
+});
+
+export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ repositoryUrl: "",
+ },
+ });
+
+ const { isSubmitting } = form.formState;
+
+ const onSubmit = async (data: z.infer) => {
+
+ const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim());
+ if (isServiceError(result)) {
+ toast({
+ title: "Error adding repository",
+ description: result.message,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Repository added successfully!",
+ description: "It will be indexed shortly.",
+ });
+ form.reset();
+ onOpenChange(false);
+ router.refresh();
+ }
+ };
+
+ const handleCancel = () => {
+ form.reset();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Add a public repository from GitHub
+
+ Paste the repo URL - the code will be indexed and available in search.
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isSubmitting ? "Adding..." : "Add Repository"}
+
+
+
+
+ );
+};
diff --git a/packages/web/src/app/[domain]/repos/layout.tsx b/packages/web/src/app/[domain]/repos/layout.tsx
index 19c0c99c..85d607eb 100644
--- a/packages/web/src/app/[domain]/repos/layout.tsx
+++ b/packages/web/src/app/[domain]/repos/layout.tsx
@@ -1,12 +1,22 @@
import { NavigationMenu } from "../components/navigationMenu";
-export default function Layout({
- children,
- params: { domain },
-}: Readonly<{
+interface LayoutProps {
children: React.ReactNode;
- params: { domain: string };
-}>) {
+ params: Promise<{ domain: string }>;
+}
+
+export default async function Layout(
+ props: LayoutProps
+) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
+ const {
+ children
+ } = props;
return (
diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx
index f0ffa1e8..4502dafc 100644
--- a/packages/web/src/app/[domain]/repos/page.tsx
+++ b/packages/web/src/app/[domain]/repos/page.tsx
@@ -2,8 +2,15 @@ import { RepositoryTable } from "./repositoryTable";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../components/pageNotFound";
import { Header } from "../components/header";
+import { env } from "@/env.mjs";
+
+export default async function ReposPage(props: { params: Promise<{ domain: string }> }) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
-export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
if (!org) {
return
@@ -16,7 +23,9 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx
index 056c0843..8d9dc0f1 100644
--- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx
+++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx
@@ -3,21 +3,32 @@
import { DataTable } from "@/components/ui/data-table";
import { columns, RepositoryColumnInfo } from "./columns";
import { unwrapServiceError } from "@/lib/utils";
-import { getRepos } from "@/actions";
import { useQuery } from "@tanstack/react-query";
import { useDomain } from "@/hooks/useDomain";
import { RepoIndexingStatus } from "@sourcebot/db";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/env.mjs";
+import { Button } from "@/components/ui/button";
+import { PlusIcon } from "lucide-react";
+import { AddRepositoryDialog } from "./components/addRepositoryDialog";
+import { useState } from "react";
+import { getRepos } from "@/app/api/(client)/client";
-export const RepositoryTable = () => {
+interface RepositoryTableProps {
+ isAddReposButtonVisible: boolean
+}
+
+export const RepositoryTable = ({
+ isAddReposButtonVisible,
+}: RepositoryTableProps) => {
const domain = useDomain();
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
- queryKey: ['repos', domain],
+ queryKey: ['repos'],
queryFn: async () => {
- return await unwrapServiceError(getRepos(domain));
+ return await unwrapServiceError(getRepos());
},
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
refetchIntervalInBackground: true,
@@ -26,24 +37,45 @@ export const RepositoryTable = () => {
const tableRepos = useMemo(() => {
if (reposLoading) return Array(4).fill(null).map(() => ({
repoId: 0,
- name: "",
- connections: [],
+ repoName: "",
+ repoDisplayName: "",
repoIndexingStatus: RepoIndexingStatus.NEW,
lastIndexed: "",
- url: "",
imageUrl: "",
}));
if (!repos) return [];
return repos.map((repo): RepositoryColumnInfo => ({
repoId: repo.repoId,
- name: repo.repoDisplayName ?? repo.repoName,
+ repoName: repo.repoName,
+ repoDisplayName: repo.repoDisplayName ?? repo.repoName,
imageUrl: repo.imageUrl,
- connections: repo.linkedConnections,
repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus,
lastIndexed: repo.indexedAt?.toISOString() ?? "",
- url: repo.webUrl ?? repo.repoCloneUrl,
})).sort((a, b) => {
+ const getPriorityFromStatus = (status: RepoIndexingStatus) => {
+ switch (status) {
+ case RepoIndexingStatus.IN_INDEX_QUEUE:
+ case RepoIndexingStatus.INDEXING:
+ return 0 // Highest priority - currently indexing
+ case RepoIndexingStatus.FAILED:
+ return 1 // Second priority - failed repos need attention
+ case RepoIndexingStatus.INDEXED:
+ return 2 // Third priority - successfully indexed
+ default:
+ return 3 // Lowest priority - other statuses (NEW, etc.)
+ }
+ }
+
+ // Sort by priority first
+ const aPriority = getPriorityFromStatus(a.repoIndexingStatus);
+ const bPriority = getPriorityFromStatus(b.repoIndexingStatus);
+
+ if (aPriority !== bPriority) {
+ return aPriority - bPriority; // Lower priority number = higher precedence
+ }
+
+ // If same priority, sort by last indexed date (most recent first)
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();
});
}, [repos, reposLoading]);
@@ -83,11 +115,28 @@ export const RepositoryTable = () => {
}
return (
-
+ <>
+ setIsAddDialogOpen(true)}
+ >
+
+ Add repository
+
+ )}
+ />
+
+
+ >
);
}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
index 8c869f8d..2d2eadbc 100644
--- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
+++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
@@ -119,7 +119,9 @@ export const CodePreview = ({
}, [onSelectedMatchIndexChange]);
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
- captureEvent('wa_preview_panel_goto_definition_pressed', {});
+ captureEvent('wa_goto_definition_pressed', {
+ source: 'preview',
+ });
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
@@ -163,7 +165,9 @@ export const CodePreview = ({
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
const onFindReferences = useCallback((symbolName: string) => {
- captureEvent('wa_preview_panel_find_references_pressed', {});
+ captureEvent('wa_find_references_pressed', {
+ source: 'preview',
+ });
createAuditAction({
action: "user.performed_find_references",
metadata: {
diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx
index e6c32c21..c4aaef20 100644
--- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx
+++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx
@@ -3,7 +3,6 @@
import { useQuery } from "@tanstack/react-query";
import { CodePreview } from "./codePreview";
import { SearchResultFile } from "@/features/search/types";
-import { useDomain } from "@/hooks/useDomain";
import { SymbolIcon } from "@radix-ui/react-icons";
import { SetStateAction, Dispatch, useMemo } from "react";
import { getFileSource } from "@/features/search/fileSourceApi";
@@ -22,7 +21,6 @@ export const CodePreviewPanel = ({
onClose,
onSelectedMatchIndexChange,
}: CodePreviewPanelProps) => {
- const domain = useDomain();
// If there are multiple branches pointing to the same revision of this file, it doesn't
// matter which branch we use here, so use the first one.
@@ -31,13 +29,13 @@ export const CodePreviewPanel = ({
}, [previewedFile]);
const { data: file, isLoading, isPending, isError } = useQuery({
- queryKey: ["source", previewedFile, branch, domain],
+ queryKey: ["source", previewedFile, branch],
queryFn: () => unwrapServiceError(
getFileSource({
fileName: previewedFile.fileName.text,
repository: previewedFile.repository,
branch,
- }, domain)
+ })
),
select: (data) => {
return {
diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx
index a24b8e45..3b1943ba 100644
--- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx
+++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx
@@ -1,27 +1,22 @@
'use client';
-import { useCallback } from "react";
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
+import Link from "next/link";
+import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
+import { useDomain } from "@/hooks/useDomain";
interface FileMatchProps {
match: SearchResultChunk;
file: SearchResultFile;
- onOpen: (startLineNumber: number, endLineNumber: number, isCtrlKeyPressed: boolean) => void;
}
export const FileMatch = ({
match,
file,
- onOpen: _onOpen,
}: FileMatchProps) => {
- const onOpen = useCallback((isCtrlKeyPressed: boolean) => {
- const startLineNumber = match.contentStart.lineNumber;
- const endLineNumber = match.content.trimEnd().split('\n').length + startLineNumber - 1;
-
- _onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed);
- }, [match.content, match.contentStart.lineNumber, _onOpen]);
+ const domain = useDomain();
// If it's just the title, don't show a code preview
if (match.matchRanges.length === 0) {
@@ -29,19 +24,24 @@ export const FileMatch = ({
}
return (
- {
- if (e.key !== "Enter") {
- return;
+ href={getBrowsePath({
+ repoName: file.repository,
+ revisionName: file.branches?.[0] ?? 'HEAD',
+ path: file.fileName.text,
+ pathType: 'blob',
+ domain,
+ highlightRange: {
+ start: {
+ lineNumber: match.contentStart.lineNumber,
+ },
+ end: {
+ lineNumber: match.content.trimEnd().split('\n').length + match.contentStart.lineNumber - 1,
+ }
}
-
- onOpen(e.metaKey || e.ctrlKey);
- }}
- onClick={(e) => {
- onOpen(e.metaKey || e.ctrlKey);
- }}
+ })}
title="open file: click, open file preview: cmd/ctrl + click"
>
{match.content}
-
+
);
}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx
index 820521b9..b10d656a 100644
--- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx
+++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx
@@ -7,7 +7,6 @@ import { useMemo } from "react";
import { FileMatch } from "./fileMatch";
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
import { Button } from "@/components/ui/button";
-import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
export const MAX_MATCHES_TO_PREVIEW = 3;
@@ -33,7 +32,6 @@ export const FileMatchContainer = ({
const matchCount = useMemo(() => {
return file.chunks.length;
}, [file]);
- const { navigateToPath } = useBrowseNavigation();
const matches = useMemo(() => {
const sortedMatches = file.chunks.sort((a, b) => {
@@ -123,29 +121,6 @@ export const FileMatchContainer = ({
{
- if (isCtrlKeyPressed) {
- const matchIndex = matches.slice(0, index).reduce((acc, match) => {
- return acc + match.matchRanges.length;
- }, 0);
- onOpenFilePreview(matchIndex);
- } else {
- navigateToPath({
- repoName: file.repository,
- revisionName: file.branches?.[0] ?? 'HEAD',
- path: file.fileName.text,
- pathType: 'blob',
- highlightRange: {
- start: {
- lineNumber: startLineNumber,
- },
- end: {
- lineNumber: endLineNumber,
- }
- }
- });
- }
- }}
/>
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx
index b44342ac..a92956d9 100644
--- a/packages/web/src/app/[domain]/search/page.tsx
+++ b/packages/web/src/app/[domain]/search/page.tsx
@@ -31,6 +31,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import { useLocalStorage } from "@uidotdev/usehooks";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
+import { SearchBar } from "../components/searchBar";
const DEFAULT_MAX_MATCH_COUNT = 10000;
@@ -172,13 +173,15 @@ const SearchPageInternal = () => {
return (
{/* TopBar */}
-
-
+
-
-
+
{(isSearchLoading) ? (
diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx
index 91a2fcfb..c52f1b55 100644
--- a/packages/web/src/app/[domain]/settings/(general)/page.tsx
+++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx
@@ -8,12 +8,18 @@ import { ErrorCode } from "@/lib/errorCodes";
import { headers } from "next/headers";
interface GeneralSettingsPageProps {
- params: {
+ params: Promise<{
domain: string;
- }
+ }>
}
-export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) {
+export default async function GeneralSettingsPage(props: GeneralSettingsPageProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
const currentUserRole = await getCurrentUserRole(domain)
if (isServiceError(currentUserRole)) {
throw new ServiceErrorException(currentUserRole);
diff --git a/packages/web/src/app/[domain]/settings/access/page.tsx b/packages/web/src/app/[domain]/settings/access/page.tsx
index cbce7b50..2fd6163d 100644
--- a/packages/web/src/app/[domain]/settings/access/page.tsx
+++ b/packages/web/src/app/[domain]/settings/access/page.tsx
@@ -1,18 +1,43 @@
import { getOrgFromDomain } from "@/data/org";
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
+import { isServiceError } from "@/lib/utils";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { getMe } from "@/actions";
+import { OrgRole } from "@sourcebot/db";
+import { redirect } from "next/navigation";
interface AccessPageProps {
- params: {
+ params: Promise<{
domain: string;
- }
+ }>
}
-export default async function AccessPage({ params: { domain } }: AccessPageProps) {
+export default async function AccessPage(props: AccessPageProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
const org = await getOrgFromDomain(domain);
if (!org) {
throw new Error("Organization not found");
}
+ const me = await getMe();
+ if (isServiceError(me)) {
+ throw new ServiceErrorException(me);
+ }
+
+ const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
+ if (!userRoleInOrg) {
+ throw new Error("User role not found");
+ }
+
+ if (userRoleInOrg !== OrgRole.OWNER) {
+ redirect(`/${domain}/settings`);
+ }
+
return (
diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx
index 87fccb7c..6c5ce8d0 100644
--- a/packages/web/src/app/[domain]/settings/billing/page.tsx
+++ b/packages/web/src/app/[domain]/settings/billing/page.tsx
@@ -16,14 +16,18 @@ export const metadata: Metadata = {
}
interface BillingPageProps {
- params: {
+ params: Promise<{
domain: string
- }
+ }>
}
-export default async function BillingPage({
- params: { domain },
-}: BillingPageProps) {
+export default async function BillingPage(props: BillingPageProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
if (!IS_BILLING_ENABLED) {
notFound();
}
diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx
index 014bb467..7259746d 100644
--- a/packages/web/src/app/[domain]/settings/layout.tsx
+++ b/packages/web/src/app/[domain]/settings/layout.tsx
@@ -13,17 +13,28 @@ import { getOrgFromDomain } from "@/data/org";
import { OrgRole } from "@prisma/client";
import { env } from "@/env.mjs";
+interface LayoutProps {
+ children: React.ReactNode;
+ params: Promise<{ domain: string }>;
+}
+
export const metadata: Metadata = {
title: "Settings",
}
-export default async function SettingsLayout({
- children,
- params: { domain },
-}: Readonly<{
- children: React.ReactNode;
- params: { domain: string };
-}>) {
+export default async function SettingsLayout(
+ props: LayoutProps
+) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
+ const {
+ children
+ } = props;
+
const session = await auth();
if (!session) {
return redirect(`/${domain}`);
@@ -70,11 +81,11 @@ export default async function SettingsLayout({
href: `/${domain}/settings/access`,
}
] : []),
- {
+ ...(userRoleInOrg === OrgRole.OWNER ? [{
title: (
Members
- {userRoleInOrg === OrgRole.OWNER && numJoinRequests !== undefined && numJoinRequests > 0 && (
+ {numJoinRequests !== undefined && numJoinRequests > 0 && (
{numJoinRequests}
@@ -82,7 +93,7 @@ export default async function SettingsLayout({
),
href: `/${domain}/settings/members`,
- },
+ }] : []),
{
title: "Secrets",
href: `/${domain}/settings/secrets`,
diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx
index af3e103f..7c6be35e 100644
--- a/packages/web/src/app/[domain]/settings/license/page.tsx
+++ b/packages/web/src/app/[domain]/settings/license/page.tsx
@@ -7,12 +7,18 @@ import { notFound, ServiceErrorException } from "@/lib/serviceError";
import { env } from "@/env.mjs";
interface LicensePageProps {
- params: {
+ params: Promise<{
domain: string;
- }
+ }>
}
-export default async function LicensePage({ params: { domain } }: LicensePageProps) {
+export default async function LicensePage(props: LicensePageProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined) {
notFound();
}
@@ -72,7 +78,7 @@ export default async function LicensePage({ params: { domain } }: LicensePagePro
-
+
Contact Support
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx
index 2c23f3f7..cffedb9d 100644
--- a/packages/web/src/app/[domain]/settings/members/page.tsx
+++ b/packages/web/src/app/[domain]/settings/members/page.tsx
@@ -12,17 +12,30 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { RequestsList } from "./components/requestsList";
import { OrgRole } from "@prisma/client";
+import { redirect } from "next/navigation";
interface MembersSettingsPageProps {
- params: {
+ params: Promise<{
domain: string
- },
- searchParams: {
+ }>,
+ searchParams: Promise<{
tab?: string
- }
+ }>
}
-export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) {
+export default async function MembersSettingsPage(props: MembersSettingsPageProps) {
+ const searchParams = await props.searchParams;
+
+ const {
+ tab
+ } = searchParams;
+
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
const org = await getOrgFromDomain(domain);
if (!org) {
throw new Error("Organization not found");
@@ -38,6 +51,10 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
throw new Error("User role not found");
}
+ if (userRoleInOrg !== OrgRole.OWNER) {
+ redirect(`/${domain}/settings`);
+ }
+
const members = await getOrgMembers(domain);
if (isServiceError(members)) {
throw new ServiceErrorException(members);
diff --git a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx
index e8a3d603..e28efe75 100644
--- a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx
+++ b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react";
import { useState, useMemo, useCallback } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { cn, getDisplayTime, isServiceError } from "@/lib/utils";
+import { getDisplayTime, isServiceError } from "@/lib/utils";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
@@ -12,6 +12,7 @@ import { deleteSecret } from "@/actions";
import { useDomain } from "@/hooks/useDomain";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
+import { CodeSnippet } from "@/app/components/codeSnippet";
interface Secret {
key: string;
@@ -138,7 +139,7 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {
Delete Secret
- Are you sure you want to delete the secret {secretToDelete?.key}? Any connections that use this secret will fail to sync.
+ Are you sure you want to delete the secret {secretToDelete?.key} ? Any connections that use this secret will fail to sync.
@@ -155,14 +156,3 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {
)
}
-
-const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
- return (
-
- {children}
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/settings/secrets/page.tsx b/packages/web/src/app/[domain]/settings/secrets/page.tsx
index c9aeab77..02714f59 100644
--- a/packages/web/src/app/[domain]/settings/secrets/page.tsx
+++ b/packages/web/src/app/[domain]/settings/secrets/page.tsx
@@ -5,12 +5,18 @@ import { ImportSecretCard } from "./components/importSecretCard";
import { ServiceErrorException } from "@/lib/serviceError";
interface SecretsPageProps {
- params: {
+ params: Promise<{
domain: string;
- }
+ }>
}
-export default async function SecretsPage({ params: { domain } }: SecretsPageProps) {
+export default async function SecretsPage(props: SecretsPageProps) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
const secrets = await getSecrets(domain);
if (isServiceError(secrets)) {
throw new ServiceErrorException(secrets);
diff --git a/packages/web/src/app/[domain]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx
index 8a51aa09..ed931e8d 100644
--- a/packages/web/src/app/[domain]/upgrade/page.tsx
+++ b/packages/web/src/app/[domain]/upgrade/page.tsx
@@ -12,7 +12,13 @@ import { env } from "@/env.mjs";
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
-export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
+export default async function Upgrade(props: { params: Promise<{ domain: string }> }) {
+ const params = await props.params;
+
+ const {
+ domain
+ } = params;
+
if (!IS_BILLING_ENABLED) {
redirect(`/${domain}`);
}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts
index c23d0cde..3238c7c5 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -1,19 +1,17 @@
'use client';
-import { getVersionResponseSchema } from "@/lib/schemas";
+import { getVersionResponseSchema, getReposResponseSchema } from "@/lib/schemas";
import { ServiceError } from "@/lib/serviceError";
-import { GetVersionResponse } from "@/lib/types";
+import { GetVersionResponse, GetReposResponse } from "@/lib/types";
import { isServiceError } from "@/lib/utils";
import {
FileSourceResponse,
FileSourceRequest,
- ListRepositoriesResponse,
SearchRequest,
SearchResponse,
} from "@/features/search/types";
import {
fileSourceResponseSchema,
- listRepositoriesResponseSchema,
searchResponseSchema,
} from "@/features/search/schemas";
@@ -47,16 +45,15 @@ export const fetchFileSource = async (body: FileSourceRequest, domain: string):
return fileSourceResponseSchema.parse(result);
}
-export const getRepos = async (domain: string): Promise
=> {
+export const getRepos = async (): Promise => {
const result = await fetch("/api/repos", {
method: "GET",
headers: {
"Content-Type": "application/json",
- "X-Org-Domain": domain,
},
}).then(response => response.json());
- return listRepositoriesResponseSchema.parse(result);
+ return getReposResponseSchema.parse(result);
}
export const getVersion = async (): Promise => {
diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts
new file mode 100644
index 00000000..2c9bced2
--- /dev/null
+++ b/packages/web/src/app/api/(server)/chat/route.ts
@@ -0,0 +1,280 @@
+import { sew, withAuth, withOrgMembership } from "@/actions";
+import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
+import { createAgentStream } from "@/features/chat/agent";
+import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
+import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils";
+import { ErrorCode } from "@/lib/errorCodes";
+import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { prisma } from "@/prisma";
+import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
+import * as Sentry from "@sentry/nextjs";
+import { OrgRole } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import {
+ createUIMessageStream,
+ createUIMessageStreamResponse,
+ JSONValue,
+ ModelMessage,
+ StreamTextResult,
+ UIMessageStreamOptions,
+ UIMessageStreamWriter
+} from "ai";
+import { randomUUID } from "crypto";
+import { StatusCodes } from "http-status-codes";
+import { z } from "zod";
+
+const logger = createLogger('chat-api');
+
+const chatRequestSchema = z.object({
+ // These paramt
+ messages: z.array(z.any()),
+ id: z.string(),
+ ...additionalChatRequestParamsSchema.shape,
+})
+
+export async function POST(req: Request) {
+ const domain = req.headers.get("X-Org-Domain");
+ if (!domain) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
+ message: "Missing X-Org-Domain header",
+ });
+ }
+
+ const requestBody = await req.json();
+ const parsed = await chatRequestSchema.safeParseAsync(requestBody);
+ if (!parsed.success) {
+ return serviceErrorResponse(schemaValidationError(parsed.error));
+ }
+
+ const { messages, id, selectedSearchScopes, languageModelId } = parsed.data;
+
+ const response = await sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ // Validate that the chat exists and is not readonly.
+ const chat = await prisma.chat.findUnique({
+ where: {
+ orgId: org.id,
+ id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ if (chat.isReadonly) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Chat is readonly and cannot be edited.",
+ });
+ }
+
+ // From the language model ID, attempt to find the
+ // corresponding config in `config.json`.
+ const languageModelConfig =
+ (await _getConfiguredLanguageModelsFull())
+ .find((model) => model.model === languageModelId);
+
+ if (!languageModelConfig) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Language model ${languageModelId} is not configured.`,
+ });
+ }
+
+ const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id);
+
+ return createMessageStreamResponse({
+ messages,
+ id,
+ selectedSearchScopes,
+ model,
+ modelName: languageModelConfig.displayName ?? languageModelConfig.model,
+ modelProviderOptions: providerOptions,
+ domain,
+ orgId: org.id,
+ });
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
+ )
+ )
+
+ if (isServiceError(response)) {
+ return serviceErrorResponse(response);
+ }
+
+ return response;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const mergeStreamAsync = async (stream: StreamTextResult, writer: UIMessageStreamWriter, options: UIMessageStreamOptions = {}) => {
+ await new Promise((resolve) => writer.merge(stream.toUIMessageStream({
+ ...options,
+ onFinish: async () => {
+ resolve();
+ }
+ })));
+}
+
+interface CreateMessageStreamResponseProps {
+ messages: SBChatMessage[];
+ id: string;
+ selectedSearchScopes: SearchScope[];
+ model: AISDKLanguageModelV2;
+ modelName: string;
+ modelProviderOptions?: Record>;
+ domain: string;
+ orgId: number;
+}
+
+const createMessageStreamResponse = async ({
+ messages,
+ id,
+ selectedSearchScopes,
+ model,
+ modelName,
+ modelProviderOptions,
+ domain,
+ orgId,
+}: CreateMessageStreamResponseProps) => {
+ const latestMessage = messages[messages.length - 1];
+ const sources = latestMessage.parts
+ .filter((part) => part.type === 'data-source')
+ .map((part) => part.data);
+
+ const traceId = randomUUID();
+
+ // Extract user messages and assistant answers.
+ // We will use this as the context we carry between messages.
+ const messageHistory =
+ messages.map((message): ModelMessage | undefined => {
+ if (message.role === 'user') {
+ return {
+ role: 'user',
+ content: message.parts[0].type === 'text' ? message.parts[0].text : '',
+ };
+ }
+
+ if (message.role === 'assistant') {
+ const answerPart = getAnswerPartFromAssistantMessage(message, false);
+ if (answerPart) {
+ return {
+ role: 'assistant',
+ content: [answerPart]
+ }
+ }
+ }
+ }).filter(message => message !== undefined);
+
+ const stream = createUIMessageStream({
+ execute: async ({ writer }) => {
+ writer.write({
+ type: 'start',
+ });
+
+ const startTime = new Date();
+
+ const expandedReposArrays = await Promise.all(selectedSearchScopes.map(async (scope) => {
+ if (scope.type === 'repo') {
+ return [scope.value];
+ }
+
+ if (scope.type === 'reposet') {
+ const reposet = await prisma.searchContext.findFirst({
+ where: {
+ orgId,
+ name: scope.value
+ },
+ include: {
+ repos: true
+ }
+ });
+
+ if (reposet) {
+ return reposet.repos.map(repo => repo.name);
+ }
+ }
+
+ return [];
+ }));
+ const expandedRepos = expandedReposArrays.flat();
+
+ const researchStream = await createAgentStream({
+ model,
+ providerOptions: modelProviderOptions,
+ inputMessages: messageHistory,
+ inputSources: sources,
+ searchScopeRepoNames: expandedRepos,
+ onWriteSource: (source) => {
+ writer.write({
+ type: 'data-source',
+ data: source,
+ });
+ },
+ traceId,
+ });
+
+ await mergeStreamAsync(researchStream, writer, {
+ sendReasoning: true,
+ sendStart: false,
+ sendFinish: false,
+ });
+
+ const totalUsage = await researchStream.totalUsage;
+
+ writer.write({
+ type: 'message-metadata',
+ messageMetadata: {
+ totalTokens: totalUsage.totalTokens,
+ totalInputTokens: totalUsage.inputTokens,
+ totalOutputTokens: totalUsage.outputTokens,
+ totalResponseTimeMs: new Date().getTime() - startTime.getTime(),
+ modelName,
+ selectedSearchScopes,
+ traceId,
+ }
+ });
+
+ writer.write({
+ type: 'finish',
+ });
+ },
+ onError: errorHandler,
+ originalMessages: messages,
+ onFinish: async ({ messages }) => {
+ await updateChatMessages({
+ chatId: id,
+ messages
+ }, domain);
+ },
+ });
+
+ return createUIMessageStreamResponse({
+ stream,
+ });
+};
+
+const errorHandler = (error: unknown) => {
+ logger.error(error);
+ Sentry.captureException(error);
+
+ if (error == null) {
+ return 'unknown error';
+ }
+
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return JSON.stringify(error);
+}
+
diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts
index 6673f0eb..acc3f9ce 100644
--- a/packages/web/src/app/api/(server)/repos/route.ts
+++ b/packages/web/src/app/api/(server)/repos/route.ts
@@ -1,24 +1,11 @@
-'use server';
-
-import { listRepositories } from "@/features/search/listReposApi";
-import { NextRequest } from "next/server";
-import { isServiceError } from "@/lib/utils";
+import { getRepos } from "@/actions";
import { serviceErrorResponse } from "@/lib/serviceError";
-import { StatusCodes } from "http-status-codes";
-import { ErrorCode } from "@/lib/errorCodes";
+import { isServiceError } from "@/lib/utils";
+import { GetReposResponse } from "@/lib/types";
-export const GET = async (request: NextRequest) => {
- const domain = request.headers.get("X-Org-Domain");
- const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
- if (!domain) {
- return serviceErrorResponse({
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
- message: "Missing X-Org-Domain header",
- });
- }
- const response = await listRepositories(domain, apiKey);
+export const GET = async () => {
+ const response: GetReposResponse = await getRepos();
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts
index 145d3fa9..83a5e6a0 100644
--- a/packages/web/src/app/api/(server)/search/route.ts
+++ b/packages/web/src/app/api/(server)/search/route.ts
@@ -5,20 +5,8 @@ import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { searchRequestSchema } from "@/features/search/schemas";
-import { ErrorCode } from "@/lib/errorCodes";
-import { StatusCodes } from "http-status-codes";
export const POST = async (request: NextRequest) => {
- const domain = request.headers.get("X-Org-Domain");
- const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
- if (!domain) {
- return serviceErrorResponse({
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
- message: "Missing X-Org-Domain header",
- });
- }
-
const body = await request.json();
const parsed = await searchRequestSchema.safeParseAsync(body);
if (!parsed.success) {
@@ -27,7 +15,7 @@ export const POST = async (request: NextRequest) => {
);
}
- const response = await search(parsed.data, domain, apiKey);
+ const response = await search(parsed.data);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts
index a6364b36..d64d701d 100644
--- a/packages/web/src/app/api/(server)/source/route.ts
+++ b/packages/web/src/app/api/(server)/source/route.ts
@@ -5,20 +5,8 @@ import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { fileSourceRequestSchema } from "@/features/search/schemas";
-import { ErrorCode } from "@/lib/errorCodes";
-import { StatusCodes } from "http-status-codes";
export const POST = async (request: NextRequest) => {
- const domain = request.headers.get("X-Org-Domain");
- const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
- if (!domain) {
- return serviceErrorResponse({
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
- message: "Missing X-Org-Domain header",
- });
- }
-
const body = await request.json();
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
if (!parsed.success) {
@@ -27,7 +15,7 @@ export const POST = async (request: NextRequest) => {
);
}
- const response = await getFileSource(parsed.data, domain, apiKey);
+ const response = await getFileSource(parsed.data);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts
index b755fcfd..1150e0d1 100644
--- a/packages/web/src/app/api/(server)/stripe/route.ts
+++ b/packages/web/src/app/api/(server)/stripe/route.ts
@@ -11,7 +11,7 @@ const logger = createLogger('stripe-webhook');
export async function POST(req: NextRequest) {
const body = await req.text();
- const signature = headers().get('stripe-signature');
+ const signature = (await headers()).get('stripe-signature');
if (!signature) {
return new Response('No signature', { status: 400 });
diff --git a/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts
index 6d967adf..b235d572 100644
--- a/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts
+++ b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts
@@ -3,17 +3,18 @@ import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
export async function GET(
- request: NextRequest,
- { params }: { params: { domain: string; repoId: string } }
+ _request: NextRequest,
+ props: { params: Promise<{ domain: string; repoId: string }> }
) {
- const { domain, repoId } = params;
+ const params = await props.params;
+ const { repoId } = params;
const repoIdNum = parseInt(repoId);
if (isNaN(repoIdNum)) {
return new Response("Invalid repo ID", { status: 400 });
}
- const result = await getRepoImage(repoIdNum, domain);
+ const result = await getRepoImage(repoIdNum);
if (isServiceError(result)) {
return new Response(result.message, { status: result.statusCode });
}
diff --git a/packages/web/src/app/codemirror-styles.css b/packages/web/src/app/codemirror-styles.css
new file mode 100644
index 00000000..f73b50e3
--- /dev/null
+++ b/packages/web/src/app/codemirror-styles.css
@@ -0,0 +1,52 @@
+/* Generic range border radius - applies 2px border radius along the perimeter */
+.cm-range-border-radius {
+ border-radius: 2px;
+}
+
+/* First line in a range: rounded top corners only */
+.cm-range-border-radius:has(+ .cm-range-border-radius):not(.cm-range-border-radius + .cm-range-border-radius) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+/* Middle lines: no rounded corners */
+.cm-range-border-radius+.cm-range-border-radius:has(+ .cm-range-border-radius) {
+ border-radius: 0;
+}
+
+/* Last line in a range: rounded bottom corners only */
+.cm-range-border-radius+.cm-range-border-radius:not(:has(+ .cm-range-border-radius)) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+/* Generic range border shadow - applies 1px box shadow along the perimeter */
+.cm-range-border-shadow {
+ /* Default to transparent if no color is set */
+ --cm-range-border-shadow-color: transparent;
+}
+
+/* Single line (not adjacent to other range border shadow elements) */
+.cm-range-border-shadow:not(.cm-range-border-shadow + .cm-range-border-shadow):not(:has(+ .cm-range-border-shadow)) {
+ box-shadow: inset 0 0 0 1px var(--cm-range-border-shadow-color);
+}
+
+/* First line in a range: top and sides only */
+.cm-range-border-shadow:has(+ .cm-range-border-shadow):not(.cm-range-border-shadow + .cm-range-border-shadow) {
+ box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color),
+ inset -1px 0 0 0 var(--cm-range-border-shadow-color),
+ inset 0 1px 0 0 var(--cm-range-border-shadow-color);
+}
+
+/* Middle lines: sides only */
+.cm-range-border-shadow+.cm-range-border-shadow:has(+ .cm-range-border-shadow) {
+ box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color),
+ inset -1px 0 0 0 var(--cm-range-border-shadow-color);
+}
+
+/* Last line in a range: bottom and sides only */
+.cm-range-border-shadow+.cm-range-border-shadow:not(:has(+ .cm-range-border-shadow)) {
+ box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color),
+ inset -1px 0 0 0 var(--cm-range-border-shadow-color),
+ inset 0 -1px 0 0 var(--cm-range-border-shadow-color);
+}
\ No newline at end of file
diff --git a/packages/web/src/app/components/codeSnippet.tsx b/packages/web/src/app/components/codeSnippet.tsx
new file mode 100644
index 00000000..93ca4de3
--- /dev/null
+++ b/packages/web/src/app/components/codeSnippet.tsx
@@ -0,0 +1,12 @@
+import { cn } from "@/lib/utils"
+
+export const CodeSnippet = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
+ return (
+
+ {children}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/components/footer.tsx b/packages/web/src/app/components/footer.tsx
index 2997cdf5..0bbb288e 100644
--- a/packages/web/src/app/components/footer.tsx
+++ b/packages/web/src/app/components/footer.tsx
@@ -8,8 +8,6 @@ export function Footer() {
Docs
- Terms
-
Security
Contact Us
diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx
index ec618e99..e240a374 100644
--- a/packages/web/src/app/components/organizationAccessSettings.tsx
+++ b/packages/web/src/app/components/organizationAccessSettings.tsx
@@ -17,7 +17,7 @@ export async function OrganizationAccessSettings() {
const metadata = getOrgMetadata(org);
const anonymousAccessEnabled = metadata?.anonymousAccessEnabled ?? false;
- const headersList = headers();
+ const headersList = await headers();
const baseUrl = getBaseUrl(headersList);
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId)
diff --git a/packages/web/src/app/components/securityCard.tsx b/packages/web/src/app/components/securityCard.tsx
index 578060f5..87662368 100644
--- a/packages/web/src/app/components/securityCard.tsx
+++ b/packages/web/src/app/components/securityCard.tsx
@@ -39,7 +39,7 @@ export default function SecurityCard() {
-
Sourcebot is open-source and trusted by thousands of developers
+
Sourcebot is fair-source and trusted by thousands of developers
{
- const { isOpen, onOpenChanged } = useSyntaxGuide();
- const previousFocusedElement = useRef
(null);
-
- const openDialog = useCallback(() => {
- previousFocusedElement.current = document.activeElement as HTMLElement;
- onOpenChanged(true);
- }, [onOpenChanged]);
-
- const closeDialog = useCallback(() => {
- onOpenChanged(false);
-
- // @note: Without requestAnimationFrame, focus was not being returned
- // to codemirror elements for some reason.
- requestAnimationFrame(() => {
- previousFocusedElement.current?.focus();
- });
- }, [onOpenChanged]);
-
- const handleOpenChange = useCallback((isOpen: boolean) => {
- if (isOpen) {
- openDialog();
- } else {
- closeDialog();
- }
- }, [closeDialog, openDialog]);
-
- useHotkeys("mod+/", (event) => {
- event.preventDefault();
- handleOpenChange(!isOpen);
- }, {
- enableOnFormTags: true,
- enableOnContentEditable: true,
- description: "Open Syntax Reference Guide",
- });
-
- return (
-
-
-
- Syntax Reference Guide
-
- Queries consist of space-separated regular expressions. Wrapping expressions in {`""`} combines them. By default, a file must have at least one match for each expression to be included.
-
-
-
-
-
- Example
- Explanation
-
-
-
-
- foo
- Match files with regex /foo/
-
-
- foo bar
- Match files with regex /foo/ and /bar/
-
-
- {`"foo bar"`}
- Match files with regex /foo bar/
-
-
-
-
-
-
- {`Multiple expressions can be or'd together with `}or, negated with -, or grouped with ().
-
-
-
-
- Example
- Explanation
-
-
-
-
- foo or bar
- Match files with regex /foo/ or /bar/
-
-
- foo -bar
- Match files with regex /foo/ but not /bar/
-
-
- foo (bar or baz)
- Match files with regex /foo/ and either /bar/ or /baz/
-
-
-
-
-
-
- Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the - prefix.
-
-
-
-
-
- Prefix
- Description
- Example
-
-
-
-
- file:
- Filter results from filepaths that match the regex. By default all files are searched.
-
-
-
- file: README
-
-
- file: {`"my file"`}
-
-
- -file: test\.ts$
-
-
-
-
-
- repo:
- Filter results from repos that match the regex. By default all repos are searched.
-
-
-
- repo: linux
-
-
- -repo: ^web/.*
-
-
-
-
-
- rev:
- Filter results from a specific branch or tag. By default only the default branch is searched.
-
-
-
- rev: beta
-
-
-
-
-
- lang:
- Filter results by language (as defined by linguist). By default all languages are searched.
-
-
-
- lang: TypeScript
-
-
- -lang: YAML
-
-
-
-
-
- sym:
- Match symbol definitions created by universal ctags at index time.
-
-
-
- sym: \bmain\b
-
-
-
-
-
-
-
-
- )
-}
-
-const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
- return (
-
- {children}
-
- )
-}
-
-const Highlight = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- )
-}
diff --git a/packages/web/src/app/components/vscodeFileIcon.tsx b/packages/web/src/app/components/vscodeFileIcon.tsx
new file mode 100644
index 00000000..9f9e4d01
--- /dev/null
+++ b/packages/web/src/app/components/vscodeFileIcon.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { cn } from "@/lib/utils";
+import { useMemo } from "react";
+import { getIconForFile } from "vscode-icons-js";
+import { Icon } from "@iconify/react";
+
+interface VscodeFileIconProps {
+ fileName: string;
+ className?: string;
+}
+
+export const VscodeFileIcon = ({ fileName, className }: VscodeFileIconProps) => {
+ const iconName = useMemo(() => {
+ const icon = getIconForFile(fileName);
+ if (icon) {
+ const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
+ return iconName;
+ }
+
+ return "vscode-icons:file-type-unknown";
+ }, [fileName]);
+
+ return ;
+}
diff --git a/packages/web/src/app/components/vscodeFolderIcon.tsx b/packages/web/src/app/components/vscodeFolderIcon.tsx
new file mode 100644
index 00000000..98b1f447
--- /dev/null
+++ b/packages/web/src/app/components/vscodeFolderIcon.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { cn } from "@/lib/utils";
+import { useMemo } from "react";
+import { getIconForFolder } from "vscode-icons-js";
+import { Icon } from "@iconify/react";
+
+interface VscodeFolderIconProps {
+ folderName: string;
+ className?: string;
+}
+
+export const VscodeFolderIcon = ({ folderName, className }: VscodeFolderIconProps) => {
+ const iconName = useMemo(() => {
+ const icon = getIconForFolder(folderName);
+ if (icon) {
+ const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
+ return iconName;
+ }
+
+ return "vscode-icons:folder";
+ }, [folderName]);
+
+ return ;
+}
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index 3a3d3643..6ab78a86 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -1,8 +1,20 @@
+@import "./codemirror-styles.css";
+
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
+ html {
+ overflow-y: scroll;
+ }
+
+ /* Hide scrollbar but keep functionality */
+ html::-webkit-scrollbar {
+ width: 0;
+ background: transparent;
+ }
+
:root {
--background: hsl(0 0% 100%);
--background-secondary: hsl(0, 0%, 98%);
@@ -17,6 +29,7 @@
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
+ --muted-accent: hsl(210, 12%, 87%);
--accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
@@ -39,6 +52,7 @@
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
+ --link: hsl(217, 91%, 60%);
--editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--editor-font-size: 13px;
@@ -83,6 +97,18 @@
--editor-tag-number: #219;
--editor-tag-regexp: #e40;
--editor-tag-variable-local: #30a;
+
+ --chat-reference: #02255f11;
+ --chat-reference-hover: #02225f22;
+ --chat-reference-selected: hsl(217, 91%, 78%);
+ --chat-reference-selected-border: hsl(217, 91%, 60%);
+
+ --chat-citation: #3b83f640;
+ --chat-citation-hover: hsl(217, 91%, 75%);
+ --chat-citation-selected: hsl(217, 91%, 65%);
+ --chat-citation-border: hsl(217, 91%, 60%);
+
+ --warning: #ca8a04;
}
.dark {
@@ -99,9 +125,10 @@
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
+ --muted-accent: hsl(218, 13%, 29%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
- --destructive: hsl(0 62.8% 30.6%);
+ --destructive: hsl(0, 78%, 57%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
@@ -120,6 +147,7 @@
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
+ --link: hsl(217, 91%, 60%);
--editor-background: var(--background);
--editor-foreground: #abb2bf;
@@ -161,6 +189,18 @@
--editor-tag-number: #e5c07b;
--editor-tag-regexp: #56b6c2;
--editor-tag-variable-local: #61afef;
+
+ --chat-reference: #2c313aad;
+ --chat-reference-hover: #374151;
+ --chat-reference-selected: hsl(217, 40%, 30%);
+ --chat-reference-selected-border: hsl(217, 91%, 60%);
+
+ --chat-citation: #1e3b8a87;
+ --chat-citation-hover: hsl(217, 91%, 45%);
+ --chat-citation-selected: hsl(217, 80%, 50%);
+ --chat-citation-border: hsl(217, 91%, 60%);
+
+ --warning: #fde047;
}
}
@@ -203,6 +243,62 @@
border-radius: 2px;
}
+/* Chat-specific styling classes */
+.chat-lineHighlight {
+ background: var(--chat-reference);
+ cursor: pointer;
+}
+
+.chat-lineHighlight-hover {
+ background: var(--chat-reference-hover);
+ cursor: pointer;
+}
+
+.chat-lineHighlight-selected {
+ background: var(--chat-reference-selected);
+ cursor: pointer;
+ --cm-range-border-shadow-color: var(--chat-reference-selected-border);
+}
+
+/* Reference states for markdown reference spans */
+.chat-reference--selected {
+ background-color: var(--chat-reference-selected) !important;
+ border-color: var(--chat-reference-selected-border) !important;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.chat-reference--hover {
+ background-color: var(--chat-reference-hover) !important;
+}
+
+/* Separate hover class for inline chat citations */
+.chat-citation--hover {
+ background-color: var(--chat-citation-hover) !important;
+}
+
+/* Separate selected class for inline chat citations */
+.chat-citation--selected {
+ background-color: var(--chat-citation-selected) !important;
+ border-color: var(--chat-citation-border) !important;
+ font-weight: 600;
+}
+
+/* Chat citation styling for inline citations in chat responses */
+.bg-chat-citation {
+ background-color: var(--chat-citation);
+ border: 1px solid var(--chat-citation-border);
+ font-weight: 500;
+}
+
+.bg-chat-citation:hover,
+.hover\:bg-chat-citation-hover:hover {
+ background-color: var(--chat-citation-hover);
+}
+
+.bg-chat-citation-hover {
+ background-color: var(--chat-citation-hover);
+}
+
.cm-editor.cm-focused {
outline: none !important;
}
@@ -215,8 +311,6 @@
text-overflow: ellipsis;
}
-
-
@layer base {
* {
@apply border-border;
@@ -257,14 +351,14 @@
.cm-editor .cm-line::selection,
.cm-editor .cm-selectionLayer .cm-selectionBackground,
.cm-content ::selection {
- background: var(--editor-selection-highlight, #ffe066) !important;
- color: var(--editor-selection-highlight-foreground, #222) !important;
+ background: var(--editor-selection-highlight, #ffe066) !important;
+ color: var(--editor-selection-highlight-foreground, #222) !important;
}
.dark .cm-editor .cm-selectionBackground,
.dark .cm-editor .cm-line::selection,
.dark .cm-editor .cm-selectionLayer .cm-selectionBackground,
.dark .cm-content ::selection {
- background: var(--editor-selection-highlight, #2563eb) !important;
- color: var(--editor-selection-highlight-foreground, #fff) !important;
+ background: var(--editor-selection-highlight, #2563eb) !important;
+ color: var(--editor-selection-highlight-foreground, #fff) !important;
}
\ No newline at end of file
diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts
index 99bb0256..d7e8b1ab 100644
--- a/packages/web/src/app/invite/actions.ts
+++ b/packages/web/src/app/invite/actions.ts
@@ -9,7 +9,7 @@ import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
-export const joinOrganization = (orgId: number, inviteLinkId?: string) => sew(async () =>
+export const joinOrganization = async (orgId: number, inviteLinkId?: string) => sew(async () =>
withAuth(async (userId) => {
const org = await prisma.org.findUnique({
where: {
diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx
index 92fa01cf..195a8d17 100644
--- a/packages/web/src/app/invite/page.tsx
+++ b/packages/web/src/app/invite/page.tsx
@@ -11,12 +11,13 @@ import { getAuthProviders } from "@/lib/authProviders";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
interface InvitePageProps {
- searchParams: {
+ searchParams: Promise<{
id?: string;
- };
+ }>;
}
-export default async function InvitePage({ searchParams }: InvitePageProps) {
+export default async function InvitePage(props: InvitePageProps) {
+ const searchParams = await props.searchParams;
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 55a98f23..dc0d4b8c 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -12,7 +12,7 @@ import { getEntitlements } from "@sourcebot/shared";
export const metadata: Metadata = {
title: "Sourcebot",
- description: "Sourcebot",
+ description: "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.",
manifest: "/manifest.json",
};
diff --git a/packages/web/src/app/login/components/demoCard.tsx b/packages/web/src/app/login/components/demoCard.tsx
deleted file mode 100644
index b9bfe93a..00000000
--- a/packages/web/src/app/login/components/demoCard.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client"
-
-import { ExternalLink } from "lucide-react"
-import Link from "next/link"
-
-import { Button } from "@/components/ui/button"
-import { Card, CardContent } from "@/components/ui/card"
-import useCaptureEvent from "@/hooks/useCaptureEvent"
-
-export default function DemoCard() {
- const captureEvent = useCaptureEvent();
-
- return (
-
-
-
-
-
-
New to Sourcebot?
-
Try our public demo before creating an account
-
-
-
- captureEvent('wa_demo_card_click', {})}
- >
- Try demo
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx
index ff0e555c..1d1eb5e3 100644
--- a/packages/web/src/app/login/components/loginForm.tsx
+++ b/packages/web/src/app/login/components/loginForm.tsx
@@ -5,14 +5,9 @@ import { Card } from "@/components/ui/card";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
import useCaptureEvent from "@/hooks/useCaptureEvent";
-import DemoCard from "@/app/login/components/demoCard";
import Link from "next/link";
-import { env } from "@/env.mjs";
import type { AuthProvider } from "@/lib/authProviders";
-const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms";
-const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
-
interface LoginFormProps {
callbackUrl?: string;
error?: string;
@@ -72,11 +67,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
{context === "login" ? "Sign in to your account" : "Create a new account"}
- {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && (
-
-
-
- )}
{error && (
@@ -102,9 +92,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
}
- {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && (
-
By signing in, you agree to the Terms of Service and Privacy Policy.
- )}
)
}
diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx
index 48e5031a..f83ac939 100644
--- a/packages/web/src/app/login/page.tsx
+++ b/packages/web/src/app/login/page.tsx
@@ -10,13 +10,14 @@ import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
const logger = createLogger('login-page');
interface LoginProps {
- searchParams: {
+ searchParams: Promise<{
callbackUrl?: string;
error?: string;
- }
+ }>
}
-export default async function Login({ searchParams }: LoginProps) {
+export default async function Login(props: LoginProps) {
+ const searchParams = await props.searchParams;
logger.info("Login page loaded");
const session = await auth();
if (session) {
diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx
index fbc746d9..b446c15f 100644
--- a/packages/web/src/app/onboard/page.tsx
+++ b/packages/web/src/app/onboard/page.tsx
@@ -1,4 +1,5 @@
import type React from "react"
+import Link from "next/link"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -14,13 +15,13 @@ import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { redirect } from "next/navigation";
-import { BetweenHorizontalStart, GitBranchIcon, LockIcon } from "lucide-react";
+import { BetweenHorizontalStart, Brain, GitBranchIcon, LockIcon } from "lucide-react";
import { hasEntitlement } from "@sourcebot/shared";
import { env } from "@/env.mjs";
import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth";
interface OnboardingProps {
- searchParams?: { step?: string };
+ searchParams?: Promise<{ step?: string }>;
}
interface OnboardingStep {
@@ -38,7 +39,8 @@ interface ResourceCard {
icon?: React.ReactNode
}
-export default async function Onboarding({ searchParams }: OnboardingProps) {
+export default async function Onboarding(props: OnboardingProps) {
+ const searchParams = await props.searchParams;
const providers = getAuthProviders();
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
const session = await auth();
@@ -87,6 +89,13 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
href: "https://docs.sourcebot.dev/docs/connections/overview",
icon: ,
},
+ {
+ id: "language-models",
+ title: "Language Models",
+ description: "Learn how to configure your language model providers to start using Ask Sourcebot",
+ href: "https://docs.sourcebot.dev/docs/configuration/language-model-providers",
+ icon: ,
+ },
{
id: "authentication-system",
title: "Authentication System",
@@ -111,7 +120,7 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
component: (
),
@@ -163,7 +172,7 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
),
diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx
index 3513e5b2..77c482f2 100644
--- a/packages/web/src/app/redeem/page.tsx
+++ b/packages/web/src/app/redeem/page.tsx
@@ -9,12 +9,13 @@ import { getOrgFromDomain } from '@/data/org';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
interface RedeemPageProps {
- searchParams: {
+ searchParams: Promise<{
invite_id?: string;
- };
+ }>;
}
-export default async function RedeemPage({ searchParams }: RedeemPageProps) {
+export default async function RedeemPage(props: RedeemPageProps) {
+ const searchParams = await props.searchParams;
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx
index cabc47a0..dc920596 100644
--- a/packages/web/src/app/signup/page.tsx
+++ b/packages/web/src/app/signup/page.tsx
@@ -10,13 +10,14 @@ import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
const logger = createLogger('signup-page');
interface LoginProps {
- searchParams: {
+ searchParams: Promise<{
callbackUrl?: string;
error?: string;
- }
+ }>
}
-export default async function Signup({ searchParams }: LoginProps) {
+export default async function Signup(props: LoginProps) {
+ const searchParams = await props.searchParams;
const session = await auth();
if (session) {
logger.info("Session found in signup page, redirecting to home");
diff --git a/packages/web/src/components/ui/accordion.tsx b/packages/web/src/components/ui/accordion.tsx
new file mode 100644
index 00000000..24c788c2
--- /dev/null
+++ b/packages/web/src/components/ui/accordion.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/packages/web/src/components/ui/animatedResizableHandle.tsx b/packages/web/src/components/ui/animatedResizableHandle.tsx
index c09635c4..db4be0df 100644
--- a/packages/web/src/components/ui/animatedResizableHandle.tsx
+++ b/packages/web/src/components/ui/animatedResizableHandle.tsx
@@ -1,11 +1,16 @@
'use client';
+import { cn } from "@/lib/utils";
import { ResizableHandle } from "./resizable";
-export const AnimatedResizableHandle = () => {
+interface AnimatedResizableHandleProps {
+ className?: string;
+}
+
+export const AnimatedResizableHandle = ({ className }: AnimatedResizableHandleProps) => {
return (
)
}
\ No newline at end of file
diff --git a/packages/web/src/components/ui/collapsible.tsx b/packages/web/src/components/ui/collapsible.tsx
new file mode 100644
index 00000000..9fa48946
--- /dev/null
+++ b/packages/web/src/components/ui/collapsible.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/packages/web/src/components/ui/data-table.tsx b/packages/web/src/components/ui/data-table.tsx
index 94f83b56..ce99592c 100644
--- a/packages/web/src/components/ui/data-table.tsx
+++ b/packages/web/src/components/ui/data-table.tsx
@@ -22,14 +22,13 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import * as React from "react"
-import { PlusIcon } from "lucide-react"
-import { env } from "@/env.mjs"
interface DataTableProps {
columns: ColumnDef[]
data: TData[]
searchKey: string
- searchPlaceholder?: string
+ searchPlaceholder?: string,
+ headerActions?: React.ReactNode,
}
export function DataTable({
@@ -37,6 +36,8 @@ export function DataTable({
data,
searchKey,
searchPlaceholder,
+ headerActions,
+
}: DataTableProps) {
const [sorting, setSorting] = React.useState([])
const [columnFilters, setColumnFilters] = React.useState(
@@ -75,18 +76,7 @@ export function DataTable({
Show a button on the demo site that allows users to add new repositories
by updating the demo-site-config.json file and opening a PR.
*/}
- {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
- {
- window.open("https://github.com/sourcebot-dev/sourcebot/edit/main/demo-site-config.json", "_blank");
- }}
- >
-
- Add repository
-
- )}
+ {headerActions}
diff --git a/packages/web/src/components/ui/dialog.tsx b/packages/web/src/components/ui/dialog.tsx
index 4d013a60..de2b9d97 100644
--- a/packages/web/src/components/ui/dialog.tsx
+++ b/packages/web/src/components/ui/dialog.tsx
@@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ closeButtonClassName?: string
+ }
+>(({ className, children, closeButtonClassName, ...props }, ref) => (
{children}
-
+
Close
diff --git a/packages/web/src/components/ui/scroll-area.tsx b/packages/web/src/components/ui/scroll-area.tsx
index 0b4a48d8..be0b2d8f 100644
--- a/packages/web/src/components/ui/scroll-area.tsx
+++ b/packages/web/src/components/ui/scroll-area.tsx
@@ -14,7 +14,8 @@ const ScrollArea = React.forwardRef<
className={cn("relative overflow-hidden", className)}
{...props}
>
-
+ {/* @see: https://github.com/radix-ui/primitives/issues/926#issuecomment-1447283516 */}
+
{children}
diff --git a/packages/web/src/components/ui/select.tsx b/packages/web/src/components/ui/select.tsx
index cbe5a36b..f28b0b86 100644
--- a/packages/web/src/components/ui/select.tsx
+++ b/packages/web/src/components/ui/select.tsx
@@ -129,7 +129,7 @@ const SelectItem = React.forwardRef<
- {children}
+ {children}
))
SelectItem.displayName = SelectPrimitive.Item.displayName
diff --git a/packages/web/src/components/ui/textarea.tsx b/packages/web/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..4d858bb6
--- /dev/null
+++ b/packages/web/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx
index 0458ecbc..831bebcd 100644
--- a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx
+++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
+import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
@@ -8,6 +8,8 @@ import { RepositoryInfo, SourceRange } from "@/features/search/types";
import { useMemo, useRef } from "react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useVirtualizer } from "@tanstack/react-virtual";
+import Link from "next/link";
+import { useDomain } from "@/hooks/useDomain";
interface ReferenceListProps {
data: FindRelatedSymbolsResponse;
@@ -21,6 +23,7 @@ export const ReferenceList = ({
data,
revisionName,
}: ReferenceListProps) => {
+ const domain = useDomain();
const repoInfoMap = useMemo(() => {
return data.repositoryInfo.reduce((acc, repo) => {
acc[repo.id] = repo;
@@ -28,7 +31,6 @@ export const ReferenceList = ({
}, {} as Record);
}, [data.repositoryInfo]);
- const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
// Virtualization setup
@@ -38,7 +40,7 @@ export const ReferenceList = ({
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
const file = data.files[index];
-
+
const estimatedSize =
file.matches.length * ESTIMATED_LINE_HEIGHT_PX +
ESTIMATED_MATCH_CONTAINER_HEIGHT_PX;
@@ -103,22 +105,26 @@ export const ReferenceList = ({
{file.matches
.sort((a, b) => a.range.start.lineNumber - b.range.start.lineNumber)
.map((match, index) => (
- {
captureEvent('wa_explore_menu_reference_clicked', {});
- navigateToPath({
- repoName: file.repository,
- revisionName,
- path: file.fileName,
- pathType: 'blob',
- highlightRange: match.range,
- })
}}
- />
+ key={index}
+ >
+
+
))}
@@ -134,21 +140,18 @@ interface ReferenceListItemProps {
lineContent: string;
range: SourceRange;
language: string;
- onClick: () => void;
}
const ReferenceListItem = ({
lineContent,
range,
language,
- onClick,
}: ReferenceListItemProps) => {
const highlightRanges = useMemo(() => [range], [range]);
return (
= ({
useHotkeys('alt+shift+f12', () => {
if (symbolInfo?.symbolName) {
- console.log('here!');
onFindReferences(symbolInfo.symbolName);
}
}, {
diff --git a/packages/web/src/ee/features/sso/sso.tsx b/packages/web/src/ee/features/sso/sso.ts
similarity index 90%
rename from packages/web/src/ee/features/sso/sso.tsx
rename to packages/web/src/ee/features/sso/sso.ts
index 966f9c79..0f14a364 100644
--- a/packages/web/src/ee/features/sso/sso.tsx
+++ b/packages/web/src/ee/features/sso/sso.ts
@@ -12,6 +12,7 @@ import Credentials from "next-auth/providers/credentials";
import type { User as AuthJsUser } from "next-auth";
import { onCreateUser } from "@/lib/authUtils";
import { createLogger } from "@sourcebot/logger";
+import { hasEntitlement } from "@sourcebot/shared";
const logger = createLogger('web-sso');
@@ -27,7 +28,17 @@ export const getSSOProviders = (): Provider[] => {
authorization: {
url: `${baseUrl}/login/oauth/authorize`,
params: {
- scope: "read:user user:email",
+ scope: [
+ 'read:user',
+ 'user:email',
+ // Permission syncing requires the `repo` scope in order to fetch repositories
+ // for the authenticated user.
+ // @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
+ ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
+ ['repo'] :
+ []
+ ),
+ ].join(' '),
},
},
token: {
@@ -103,7 +114,7 @@ export const getSSOProviders = (): Provider[] => {
}
const oauth2Client = new OAuth2Client();
-
+
const { pubkeys } = await oauth2Client.getIapPublicKeys();
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
iapAssertion,
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs
index 5218f94e..922b2b84 100644
--- a/packages/web/src/env.mjs
+++ b/packages/web/src/env.mjs
@@ -91,11 +91,53 @@ export const env = createEnv({
GITHUB_APP_ID: z.string().optional(),
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
GITHUB_APP_PRIVATE_KEY_PATH: z.string().optional(),
- OPENAI_API_KEY: z.string().optional(),
REVIEW_AGENT_API_KEY: z.string().optional(),
REVIEW_AGENT_LOGGING_ENABLED: booleanSchema.default('true'),
REVIEW_AGENT_AUTO_REVIEW_ENABLED: booleanSchema.default('false'),
REVIEW_AGENT_REVIEW_COMMAND: z.string().default('review'),
+
+ ANTHROPIC_API_KEY: z.string().optional(),
+ ANTHROPIC_THINKING_BUDGET_TOKENS: numberSchema.default(12000),
+
+ AZURE_API_KEY: z.string().optional(),
+ AZURE_RESOURCE_NAME: z.string().optional(),
+
+ DEEPSEEK_API_KEY: z.string().optional(),
+
+ OPENAI_API_KEY: z.string().optional(),
+
+ OPENROUTER_API_KEY: z.string().optional(),
+
+ XAI_API_KEY: z.string().optional(),
+
+ MISTRAL_API_KEY: z.string().optional(),
+
+ GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(),
+ GOOGLE_VERTEX_PROJECT: z.string().optional(),
+ GOOGLE_VERTEX_REGION: z.string().default('us-central1'),
+ GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
+ GOOGLE_VERTEX_THINKING_BUDGET_TOKENS: numberSchema.default(-1),
+ GOOGLE_VERTEX_INCLUDE_THOUGHTS: booleanSchema.default('true'),
+
+ AWS_ACCESS_KEY_ID: z.string().optional(),
+ AWS_SECRET_ACCESS_KEY: z.string().optional(),
+ AWS_SESSION_TOKEN: z.string().optional(),
+ AWS_REGION: z.string().optional(),
+
+ SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.default(0.3),
+ SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(20),
+
+ DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
+
+ LANGFUSE_SECRET_KEY: z.string().optional(),
+
+ SOURCEBOT_DEMO_EXAMPLES_PATH: z.string().optional(),
+
+ EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
+ // @NOTE: Take care to update actions.ts when changing the name of this.
+ EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(),
+
+ EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
},
// @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in
@@ -110,6 +152,9 @@ export const env = createEnv({
NEXT_PUBLIC_POLLING_INTERVAL_MS: numberSchema.default(5000),
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: z.enum(SOURCEBOT_CLOUD_ENVIRONMENT).optional(),
+
+ NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY: z.string().optional(),
+ NEXT_PUBLIC_LANGFUSE_BASE_URL: z.string().optional()
},
// For Next.js >= 13.4.4, you only need to destructure client variables:
experimental__runtimeEnv: {
@@ -117,6 +162,8 @@ export const env = createEnv({
NEXT_PUBLIC_SOURCEBOT_VERSION: process.env.NEXT_PUBLIC_SOURCEBOT_VERSION,
NEXT_PUBLIC_POLLING_INTERVAL_MS: process.env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT,
+ NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY: process.env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
+ NEXT_PUBLIC_LANGFUSE_BASE_URL: process.env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
},
skipValidation: process.env.SKIP_ENV_VALIDATION === "1",
emptyStringAsUndefined: true,
diff --git a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
index 06cc7a44..7617c959 100644
--- a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
@@ -2,7 +2,6 @@ import { sourcebot_context, sourcebot_pr_payload } from "@/features/agents/revie
import { getFileSource } from "@/features/search/fileSourceApi";
import { fileSourceResponseSchema } from "@/features/search/schemas";
import { isServiceError } from "@/lib/utils";
-import { env } from "@/env.mjs";
import { createLogger } from "@sourcebot/logger";
const logger = createLogger('fetch-file-content');
@@ -17,7 +16,7 @@ export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filenam
}
logger.debug(JSON.stringify(fileSourceRequest, null, 2));
- const response = await getFileSource(fileSourceRequest, "~", env.REVIEW_AGENT_API_KEY);
+ const response = await getFileSource(fileSourceRequest);
if (isServiceError(response)) {
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`);
}
diff --git a/packages/web/src/features/chat/README.md b/packages/web/src/features/chat/README.md
new file mode 100644
index 00000000..79d53476
--- /dev/null
+++ b/packages/web/src/features/chat/README.md
@@ -0,0 +1,15 @@
+## Sources and References
+
+We have the concept of "sources" and "references" in sb:
+1. **source** - A source is some artifact that exists in the codebase (e.g., file, commit, etc.) that helps the LLM ground its answer in reality.
+2. **references** - A reference (or citation) is a _pointer_ to a source that the LLM can output in it's response so that the developer can understand why the LLM got to the conclusion it got to.
+
+Sources can be attached to a chat thread in two ways:
+1. The developer @ mentions a source (e.g., _"what does `@auth.ts` do?"_) in their request.
+2. The LLM makes a tool call (e.g., `readFile`) in its response.
+
+Sources are included in the chat thread using a [custom data part](https://v5.ai-sdk.dev/docs/ai-sdk-ui/streaming-data#streaming-custom-data) as a JSON payload with the necessary data to allow us to retrieve the source at a later point (e.g., in `ReferencedSourcesListView.tsx`).
+
+References are included in a LLMs response by embedding a known pattern (e.g., `@file:{auth.ts:12-24}`) that can be grepped and rendered with a custom component using a [remark plugin](https://github.com/remarkjs/remark). The LLM is instructed to use this pattern in the system prompt.
+
+The process of resolving a reference to a source is inherently fuzzy since we are not guaranteed any determinism with LLMs (e.g., the LLM could hallucinate a source that doesn't exist). We perform reference resolution on a best-effort basis.
\ No newline at end of file
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
new file mode 100644
index 00000000..4f93ac1d
--- /dev/null
+++ b/packages/web/src/features/chat/actions.ts
@@ -0,0 +1,632 @@
+'use server';
+
+import { sew, withAuth, withOrgMembership } from "@/actions";
+import { env } from "@/env.mjs";
+import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants";
+import { ErrorCode } from "@/lib/errorCodes";
+import { chatIsReadonly, notFound, ServiceError, serviceErrorResponse } from "@/lib/serviceError";
+import { prisma } from "@/prisma";
+import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
+import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
+import { createAzure } from '@ai-sdk/azure';
+import { createDeepSeek } from '@ai-sdk/deepseek';
+import { createGoogleGenerativeAI } from '@ai-sdk/google';
+import { createVertex } from '@ai-sdk/google-vertex';
+import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
+import { createMistral } from '@ai-sdk/mistral';
+import { createOpenAI, OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
+import { createXai } from '@ai-sdk/xai';
+import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
+import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+import { getTokenFromConfig } from "@sourcebot/crypto";
+import { ChatVisibility, OrgRole, Prisma, PrismaClient } from "@sourcebot/db";
+import { LanguageModel } from "@sourcebot/schemas/v3/languageModel.type";
+import { Token } from "@sourcebot/schemas/v3/shared.type";
+import { loadConfig } from "@sourcebot/shared";
+import { generateText, JSONValue } from "ai";
+import fs from 'fs';
+import { StatusCodes } from "http-status-codes";
+import path from 'path';
+import { LanguageModelInfo, SBChatMessage } from "./types";
+
+export const createChat = async (domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+
+ const isGuestUser = userId === SOURCEBOT_GUEST_USER_ID;
+
+ const chat = await prisma.chat.create({
+ data: {
+ orgId: org.id,
+ messages: [] as unknown as Prisma.InputJsonValue,
+ createdById: userId,
+ visibility: isGuestUser ? ChatVisibility.PUBLIC : ChatVisibility.PRIVATE,
+ },
+ });
+
+ return {
+ id: chat.id,
+ }
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
+);
+
+export const getChatInfo = async ({ chatId }: { chatId: string }, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) {
+ return notFound();
+ }
+
+ return {
+ messages: chat.messages as unknown as SBChatMessage[],
+ visibility: chat.visibility,
+ name: chat.name,
+ isReadonly: chat.isReadonly,
+ };
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
+);
+
+export const updateChatMessages = async ({ chatId, messages }: { chatId: string, messages: SBChatMessage[] }, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) {
+ return notFound();
+ }
+
+ if (chat.isReadonly) {
+ return chatIsReadonly();
+ }
+
+ await prisma.chat.update({
+ where: {
+ id: chatId,
+ },
+ data: {
+ messages: messages as unknown as Prisma.InputJsonValue,
+ },
+ });
+
+ if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
+ const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
+ if (!fs.existsSync(chatDir)) {
+ fs.mkdirSync(chatDir, { recursive: true });
+ }
+
+ const chatFile = path.join(chatDir, `${chatId}.json`);
+ fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
+ }
+
+ return {
+ success: true,
+ }
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
+);
+
+export const getUserChatHistory = async (domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chats = await prisma.chat.findMany({
+ where: {
+ orgId: org.id,
+ createdById: userId,
+ },
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ });
+
+ return chats.map((chat) => ({
+ id: chat.id,
+ createdAt: chat.createdAt,
+ name: chat.name,
+ visibility: chat.visibility,
+ }))
+ })
+ )
+);
+
+export const updateChatName = async ({ chatId, name }: { chatId: string, name: string }, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) {
+ return notFound();
+ }
+
+ if (chat.isReadonly) {
+ return chatIsReadonly();
+ }
+
+ await prisma.chat.update({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ data: {
+ name,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
+);
+
+export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ // From the language model ID, attempt to find the
+ // corresponding config in `config.json`.
+ const languageModelConfig =
+ (await _getConfiguredLanguageModelsFull())
+ .find((model) => model.model === languageModelId);
+
+ if (!languageModelConfig) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Language model ${languageModelId} is not configured.`,
+ });
+ }
+
+ const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id);
+
+ const prompt = `Convert this question into a short topic title (max 50 characters).
+
+Rules:
+- Do NOT include question words (what, where, how, why, when, which)
+- Do NOT end with a question mark
+- Capitalize the first letter of the title
+- Focus on the subject/topic being discussed
+- Make it sound like a file name or category
+
+Examples:
+"Where is the authentication code?" → "Authentication Code"
+"How to setup the database?" → "Database Setup"
+"What are the API endpoints?" → "API Endpoints"
+
+User question: ${message}`;
+
+ const result = await generateText({
+ model,
+ prompt,
+ });
+
+ await updateChatName({
+ chatId,
+ name: result.text,
+ }, domain);
+
+ return {
+ success: true,
+ }
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
+ )
+);
+
+export const deleteChat = async ({ chatId }: { chatId: string }, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ // Public chats cannot be deleted.
+ if (chat.visibility === ChatVisibility.PUBLIC) {
+ return {
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.UNEXPECTED_ERROR,
+ message: 'You are not allowed to delete this chat.',
+ } satisfies ServiceError;
+ }
+
+ // Only the creator of a chat can delete it.
+ if (chat.createdById !== userId) {
+ return notFound();
+ }
+
+ await prisma.chat.delete({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ })
+ )
+);
+
+export const submitFeedback = async ({
+ chatId,
+ messageId,
+ feedbackType
+}: {
+ chatId: string,
+ messageId: string,
+ feedbackType: 'like' | 'dislike'
+}, domain: string) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ // When a chat is private, only the creator can submit feedback.
+ if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) {
+ return notFound();
+ }
+
+ const messages = chat.messages as unknown as SBChatMessage[];
+ const updatedMessages = messages.map(message => {
+ if (message.id === messageId && message.role === 'assistant') {
+ return {
+ ...message,
+ metadata: {
+ ...message.metadata,
+ feedback: [
+ ...(message.metadata?.feedback ?? []),
+ {
+ type: feedbackType,
+ timestamp: new Date().toISOString(),
+ userId: userId,
+ }
+ ]
+ }
+ } satisfies SBChatMessage;
+ }
+ return message;
+ });
+
+ await prisma.chat.update({
+ where: { id: chatId },
+ data: {
+ messages: updatedMessages as unknown as Prisma.InputJsonValue,
+ },
+ });
+
+ return { success: true };
+ }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
+);
+
+/**
+ * Returns the subset of information about the configured language models
+ * that we can safely send to the client.
+ */
+export const getConfiguredLanguageModelsInfo = async (): Promise => {
+ const models = await _getConfiguredLanguageModelsFull();
+ return models.map((model): LanguageModelInfo => ({
+ provider: model.provider,
+ model: model.model,
+ displayName: model.displayName,
+ }));
+}
+
+/**
+ * Returns the full configuration of the language models.
+ *
+ * @warning Do NOT call this function from the client,
+ * or pass the result of calling this function to the client.
+ */
+export const _getConfiguredLanguageModelsFull = async (): Promise => {
+ if (!env.CONFIG_PATH) {
+ return [];
+ }
+
+ try {
+ const config = await loadConfig(env.CONFIG_PATH);
+ return config.models ?? [];
+ } catch (error) {
+ console.error(`Failed to load config file ${env.CONFIG_PATH}: ${error}`);
+ return [];
+ }
+}
+
+
+export const _getAISDKLanguageModelAndOptions = async (config: LanguageModel, orgId: number): Promise<{
+ model: AISDKLanguageModelV2,
+ providerOptions?: Record>,
+}> => {
+ const { provider, model: modelId } = config;
+
+ switch (provider) {
+ case 'amazon-bedrock': {
+ const aws = createAmazonBedrock({
+ baseURL: config.baseUrl,
+ region: config.region ?? env.AWS_REGION,
+ accessKeyId: config.accessKeyId
+ ? await getTokenFromConfig(config.accessKeyId, orgId, prisma)
+ : env.AWS_ACCESS_KEY_ID,
+ secretAccessKey: config.accessKeySecret
+ ? await getTokenFromConfig(config.accessKeySecret, orgId, prisma)
+ : env.AWS_SECRET_ACCESS_KEY,
+ sessionToken: config.sessionToken
+ ? await getTokenFromConfig(config.sessionToken, orgId, prisma)
+ : env.AWS_SESSION_TOKEN,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ // Fallback to the default Node.js credential provider chain if no credentials are provided.
+ // See: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain
+ credentialProvider: !config.accessKeyId && !config.accessKeySecret && !config.sessionToken
+ ? fromNodeProviderChain()
+ : undefined,
+ });
+
+ return {
+ model: aws(modelId),
+ };
+ }
+ case 'anthropic': {
+ const anthropic = createAnthropic({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.ANTHROPIC_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: anthropic(modelId),
+ providerOptions: {
+ anthropic: {
+ thinking: {
+ type: "enabled",
+ budgetTokens: env.ANTHROPIC_THINKING_BUDGET_TOKENS,
+ }
+ } satisfies AnthropicProviderOptions,
+ },
+ };
+ }
+ case 'azure': {
+ const azure = createAzure({
+ baseURL: config.baseUrl,
+ apiKey: config.token ? (await getTokenFromConfig(config.token, orgId, prisma)) : env.AZURE_API_KEY,
+ apiVersion: config.apiVersion,
+ resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: azure(modelId),
+ };
+ }
+ case 'deepseek': {
+ const deepseek = createDeepSeek({
+ baseURL: config.baseUrl,
+ apiKey: config.token ? (await getTokenFromConfig(config.token, orgId, prisma)) : env.DEEPSEEK_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: deepseek(modelId),
+ };
+ }
+ case 'google-generative-ai': {
+ const google = createGoogleGenerativeAI({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.GOOGLE_GENERATIVE_AI_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: google(modelId),
+ };
+ }
+ case 'google-vertex': {
+ const vertex = createVertex({
+ project: config.project ?? env.GOOGLE_VERTEX_PROJECT,
+ location: config.region ?? env.GOOGLE_VERTEX_REGION,
+ ...(config.credentials ? {
+ googleAuthOptions: {
+ keyFilename: await getTokenFromConfig(config.credentials, orgId, prisma),
+ }
+ } : {}),
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: vertex(modelId),
+ providerOptions: {
+ google: {
+ thinkingConfig: {
+ thinkingBudget: env.GOOGLE_VERTEX_THINKING_BUDGET_TOKENS,
+ includeThoughts: env.GOOGLE_VERTEX_INCLUDE_THOUGHTS === 'true',
+ }
+ }
+ },
+ };
+ }
+ case 'google-vertex-anthropic': {
+ const vertexAnthropic = createVertexAnthropic({
+ project: config.project ?? env.GOOGLE_VERTEX_PROJECT,
+ location: config.region ?? env.GOOGLE_VERTEX_REGION,
+ ...(config.credentials ? {
+ googleAuthOptions: {
+ keyFilename: await getTokenFromConfig(config.credentials, orgId, prisma),
+ }
+ } : {}),
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: vertexAnthropic(modelId),
+ };
+ }
+ case 'mistral': {
+ const mistral = createMistral({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.MISTRAL_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: mistral(modelId),
+ };
+ }
+ case 'openai': {
+ const openai = createOpenAI({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.OPENAI_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: openai(modelId),
+ providerOptions: {
+ openai: {
+ reasoningEffort: config.reasoningEffort ?? 'medium',
+ } satisfies OpenAIResponsesProviderOptions,
+ },
+ };
+ }
+ case 'openai-compatible': {
+ const openai = createOpenAICompatible({
+ baseURL: config.baseUrl,
+ name: config.displayName ?? modelId,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : undefined,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ queryParams: config.queryParams
+ ? await extractLanguageModelKeyValuePairs(config.queryParams, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: openai.chatModel(modelId),
+ }
+ }
+ case 'openrouter': {
+ const openrouter = createOpenRouter({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.OPENROUTER_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: openrouter(modelId),
+ };
+ }
+ case 'xai': {
+ const xai = createXai({
+ baseURL: config.baseUrl,
+ apiKey: config.token
+ ? await getTokenFromConfig(config.token, orgId, prisma)
+ : env.XAI_API_KEY,
+ headers: config.headers
+ ? await extractLanguageModelKeyValuePairs(config.headers, orgId, prisma)
+ : undefined,
+ });
+
+ return {
+ model: xai(modelId),
+ };
+ }
+ }
+}
+
+const extractLanguageModelKeyValuePairs = async (
+ pairs: {
+ [k: string]: string | Token;
+ },
+ orgId: number,
+ db: PrismaClient,
+): Promise> => {
+ const resolvedPairs: Record = {};
+
+ if (!pairs) {
+ return resolvedPairs;
+ }
+
+ for (const [key, val] of Object.entries(pairs)) {
+ if (typeof val === "string") {
+ resolvedPairs[key] = val;
+ continue;
+ }
+
+ const value = await getTokenFromConfig(val, orgId, db);
+ resolvedPairs[key] = value;
+ }
+
+ return resolvedPairs;
+}
diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts
new file mode 100644
index 00000000..7df5a6d4
--- /dev/null
+++ b/packages/web/src/features/chat/agent.ts
@@ -0,0 +1,269 @@
+import { env } from "@/env.mjs";
+import { getFileSource } from "@/features/search/fileSourceApi";
+import { isServiceError } from "@/lib/utils";
+import { ProviderOptions } from "@ai-sdk/provider-utils";
+import { createLogger } from "@sourcebot/logger";
+import { LanguageModel, ModelMessage, StopCondition, streamText } from "ai";
+import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants";
+import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, readFilesTool, searchReposTool, listAllReposTool } from "./tools";
+import { FileSource, Source } from "./types";
+import { addLineNumbers, fileReferenceToString } from "./utils";
+
+const logger = createLogger('chat-agent');
+
+interface AgentOptions {
+ model: LanguageModel;
+ providerOptions?: ProviderOptions;
+ headers?: Record;
+ searchScopeRepoNames: string[];
+ inputMessages: ModelMessage[];
+ inputSources: Source[];
+ onWriteSource: (source: Source) => void;
+ traceId: string;
+}
+
+// If the agent exceeds the step count, then we will stop.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const stepCountIsGTE = (stepCount: number): StopCondition => {
+ return ({ steps }) => steps.length >= stepCount;
+}
+
+export const createAgentStream = async ({
+ model,
+ providerOptions,
+ inputMessages,
+ inputSources,
+ searchScopeRepoNames,
+ onWriteSource,
+ traceId,
+}: AgentOptions) => {
+ const baseSystemPrompt = createBaseSystemPrompt({
+ searchScopeRepoNames,
+ });
+
+ const stream = streamText({
+ model,
+ providerOptions,
+ system: baseSystemPrompt,
+ messages: inputMessages,
+ tools: {
+ [toolNames.searchCode]: createCodeSearchTool(searchScopeRepoNames),
+ [toolNames.readFiles]: readFilesTool,
+ [toolNames.findSymbolReferences]: findSymbolReferencesTool,
+ [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool,
+ [toolNames.searchRepos]: searchReposTool,
+ [toolNames.listAllRepos]: listAllReposTool,
+ },
+ prepareStep: async ({ stepNumber }) => {
+ // The first step attaches any mentioned sources to the system prompt.
+ if (stepNumber === 0 && inputSources.length > 0) {
+ const fileSources = inputSources.filter((source) => source.type === 'file');
+
+ const resolvedFileSources = (
+ await Promise.all(fileSources.map(resolveFileSource)))
+ .filter((source) => source !== undefined)
+
+ const fileSourcesSystemPrompt = await createFileSourcesSystemPrompt({
+ files: resolvedFileSources
+ });
+
+ return {
+ system: `${baseSystemPrompt}\n\n${fileSourcesSystemPrompt}`
+ }
+ }
+
+ if (stepNumber === env.SOURCEBOT_CHAT_MAX_STEP_COUNT - 1) {
+ return {
+ system: `**CRITICAL**: You have reached the maximum number of steps!! YOU MUST PROVIDE YOUR FINAL ANSWER NOW. DO NOT KEEP RESEARCHING.\n\n${answerInstructions}`,
+ activeTools: [],
+ }
+ }
+
+ return undefined;
+ },
+ temperature: env.SOURCEBOT_CHAT_MODEL_TEMPERATURE,
+ stopWhen: [
+ stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT),
+ ],
+ toolChoice: "auto", // Let the model decide when to use tools
+ onStepFinish: ({ toolResults }) => {
+ // This takes care of extracting any sources that the LLM has seen as part of
+ // the tool calls it made.
+ toolResults.forEach(({ toolName, output, dynamic }) => {
+ // we don't care about dynamic tool results here.
+ if (dynamic) {
+ return;
+ }
+
+ if (isServiceError(output)) {
+ // is there something we want to do here?
+ return;
+ }
+
+ if (toolName === toolNames.readFiles) {
+ output.forEach((file) => {
+ onWriteSource({
+ type: 'file',
+ language: file.language,
+ repo: file.repository,
+ path: file.path,
+ revision: file.revision,
+ name: file.path.split('/').pop() ?? file.path,
+ })
+ })
+ }
+ else if (toolName === toolNames.searchCode) {
+ output.files.forEach((file) => {
+ onWriteSource({
+ type: 'file',
+ language: file.language,
+ repo: file.repository,
+ path: file.fileName,
+ revision: file.revision,
+ name: file.fileName.split('/').pop() ?? file.fileName,
+ })
+ })
+ }
+ else if (toolName === toolNames.findSymbolDefinitions || toolName === toolNames.findSymbolReferences) {
+ output.forEach((file) => {
+ onWriteSource({
+ type: 'file',
+ language: file.language,
+ repo: file.repository,
+ path: file.fileName,
+ revision: file.revision,
+ name: file.fileName.split('/').pop() ?? file.fileName,
+ })
+ })
+ }
+ })
+ },
+ // Only enable langfuse traces in cloud environments.
+ experimental_telemetry: {
+ isEnabled: env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined,
+ metadata: {
+ langfuseTraceId: traceId,
+ },
+ },
+ onError: (error) => {
+ logger.error(error);
+ },
+ });
+
+ return stream;
+}
+
+interface BaseSystemPromptOptions {
+ searchScopeRepoNames: string[];
+}
+
+export const createBaseSystemPrompt = ({
+ searchScopeRepoNames,
+}: BaseSystemPromptOptions) => {
+ return `
+You are a powerful agentic AI code assistant built into Sourcebot, the world's best code-intelligence platform. Your job is to help developers understand and navigate their large codebases.
+
+
+Your workflow has two distinct phases:
+
+**Phase 1: Research & Analysis**
+- Analyze the user's question and determine what context you need
+- Use available tools to gather code, search repositories, find references, etc.
+- Think through the problem and collect all relevant information
+- Do NOT provide partial answers or explanations during this phase
+
+**Phase 2: Structured Response**
+- **MANDATORY**: You MUST always enter this phase and provide a structured markdown response, regardless of whether phase 1 was completed or interrupted
+- Provide your final response based on whatever context you have available
+- Always format your response according to the required response format below
+
+
+
+The user has selected the following repositories for analysis:
+${searchScopeRepoNames.map(repo => `- ${repo}`).join('\n')}
+
+
+
+During the research phase, use the tools available to you to gather comprehensive context before answering. Always explain why you're using each tool. Depending on the user's question, you may need to use multiple tools. If the question is vague, ask the user for more information.
+
+
+${answerInstructions}
+`;
+}
+
+const answerInstructions = `
+
+When you have sufficient context, output your answer as a structured markdown response.
+
+**Required Response Format:**
+- **CRITICAL**: You MUST always prefix your answer with a \`${ANSWER_TAG}\` tag at the very top of your response
+- **CRITICAL**: You MUST provide your complete response in markdown format with embedded code references
+- **CODE REFERENCE REQUIREMENT**: Whenever you mention, discuss, or refer to ANY specific part of the code (files, functions, variables, methods, classes, imports, etc.), you MUST immediately follow with a code reference using the format \`${fileReferenceToString({ repo: 'repository', path: 'filename'})}\` or \`${fileReferenceToString({ repo: 'repository', path: 'filename', range: { startLine: 1, endLine: 10 } })}\` (where the numbers are the start and end line numbers of the code snippet). This includes:
+ - Files (e.g., "The \`auth.ts\` file" → must include \`${fileReferenceToString({ repo: 'repository', path: 'auth.ts' })}\`)
+ - Function names (e.g., "The \`getRepos()\` function" → must include \`${fileReferenceToString({ repo: 'repository', path: 'auth.ts', range: { startLine: 15, endLine: 20 } })}\`)
+ - Variable names (e.g., "The \`suggestionQuery\` variable" → must include \`${fileReferenceToString({ repo: 'repository', path: 'search.ts', range: { startLine: 42, endLine: 42 } })}\`)
+ - Any code snippet or line you're explaining
+ - Class names, method calls, imports, etc.
+- Some examples of both correct and incorrect code references:
+ - Correct: @file:{repository::path/to/file.ts}
+ - Correct: @file:{repository::path/to/file.ts:10-15}
+ - Incorrect: @file{repository::path/to/file.ts} (missing colon)
+ - Incorrect: @file:repository::path/to/file.ts (missing curly braces)
+ - Incorrect: @file:{repository::path/to/file.ts:10-25,30-35} (multiple ranges not supported)
+ - Incorrect: @file:{path/to/file.ts} (missing repository)
+- Be clear and very concise. Use bullet points where appropriate
+- Do NOT explain code without providing the exact location reference. Every code mention requires a corresponding \`${FILE_REFERENCE_PREFIX}\` reference
+- If you cannot provide a code reference for something you're discussing, do not mention that specific code element
+- Always prefer to use \`${FILE_REFERENCE_PREFIX}\` over \`\`\`code\`\`\` blocks.
+
+**Example answer structure:**
+\`\`\`markdown
+${ANSWER_TAG}
+Authentication in Sourcebot is built on NextAuth.js with a session-based approach using JWT tokens and Prisma as the database adapter ${fileReferenceToString({ repo: 'github.com/sourcebot-dev/sourcebot', path: 'auth.ts', range: { startLine: 135, endLine: 140 } })}. The system supports multiple authentication providers and implements organization-based authorization with role-defined permissions.
+\`\`\`
+
+
+`;
+
+interface FileSourcesSystemPromptOptions {
+ files: {
+ path: string;
+ source: string;
+ repo: string;
+ language: string;
+ revision: string;
+ }[];
+}
+
+const createFileSourcesSystemPrompt = async ({ files }: FileSourcesSystemPromptOptions) => {
+ return `
+The user has mentioned the following files, which are automatically included for analysis.
+
+${files.map(file => `
+${addLineNumbers(file.source)}
+ `).join('\n\n')}
+ `.trim();
+}
+
+const resolveFileSource = async ({ path, repo, revision }: FileSource) => {
+ const fileSource = await getFileSource({
+ fileName: path,
+ repository: repo,
+ branch: revision,
+ // @todo: handle multi-tenancy.
+ });
+
+ if (isServiceError(fileSource)) {
+ // @todo: handle this
+ logger.error("Error fetching file source:", fileSource)
+ return undefined;
+ }
+
+ return {
+ path,
+ source: fileSource.source,
+ repo,
+ language: fileSource.language,
+ revision,
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/atMentionInfoCard.tsx b/packages/web/src/features/chat/components/chatBox/atMentionInfoCard.tsx
new file mode 100644
index 00000000..74424074
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/atMentionInfoCard.tsx
@@ -0,0 +1,15 @@
+import { AtSignIcon } from "lucide-react";
+
+export const AtMentionInfoCard = () => {
+ return (
+
+
+
+ When asking Sourcebot a question, you can @ mention files to include them in the context of the search.
+
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx
new file mode 100644
index 00000000..934199f2
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx
@@ -0,0 +1,423 @@
+'use client';
+
+import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
+import { insertMention, slateContentToString } from "@/features/chat/utils";
+import { cn, IS_MAC } from "@/lib/utils";
+import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react";
+import { ArrowUp, Loader2, StopCircleIcon, TriangleAlertIcon } from "lucide-react";
+import { Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { Descendant, insertText } from "slate";
+import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, useFocused, useSelected, useSlate } from "slate-react";
+import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
+import { SuggestionBox } from "./suggestionsBox";
+import { Suggestion } from "./types";
+import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
+import { useSuggestionsData } from "./useSuggestionsData";
+import { useToast } from "@/components/hooks/use-toast";
+import { SearchContextQuery } from "@/lib/types";
+
+interface ChatBoxProps {
+ onSubmit: (children: Descendant[], editor: CustomEditor) => void;
+ onStop?: () => void;
+ preferredSuggestionsBoxPlacement?: "top-start" | "bottom-start";
+ className?: string;
+ isRedirecting?: boolean;
+ isGenerating?: boolean;
+ languageModels: LanguageModelInfo[];
+ selectedSearchScopes: SearchScope[];
+ searchContexts: SearchContextQuery[];
+ onContextSelectorOpenChanged: (isOpen: boolean) => void;
+}
+
+export const ChatBox = ({
+ onSubmit: _onSubmit,
+ onStop,
+ preferredSuggestionsBoxPlacement = "bottom-start",
+ className,
+ isRedirecting,
+ isGenerating,
+ languageModels,
+ selectedSearchScopes,
+ searchContexts,
+ onContextSelectorOpenChanged,
+}: ChatBoxProps) => {
+ const suggestionsBoxRef = useRef(null);
+ const [index, setIndex] = useState(0);
+ const editor = useSlate();
+ const { suggestionQuery, suggestionMode, range } = useSuggestionModeAndQuery();
+ const { suggestions, isLoading } = useSuggestionsData({
+ suggestionMode,
+ suggestionQuery,
+ selectedRepos: selectedSearchScopes.map((item) => {
+ if (item.type === 'repo') {
+ return [item.value];
+ }
+
+ if (item.type === 'reposet') {
+ const reposet = searchContexts.find((reposet) => reposet.name === item.value);
+ if (reposet) {
+ return reposet.repoNames;
+ }
+ }
+
+ return [];
+ }).flat(),
+ });
+ const { selectedLanguageModel } = useSelectedLanguageModel({
+ initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
+ });
+ const { toast } = useToast();
+
+ // Reset the index when the suggestion mode changes.
+ useEffect(() => {
+ setIndex(0);
+ }, [suggestionMode]);
+
+ // Hotkey to focus the chat box.
+ useHotkeys("/", (e) => {
+ e.preventDefault();
+ ReactEditor.focus(editor);
+ });
+
+ // Auto-focus chat box when the component mounts.
+ useEffect(() => {
+ ReactEditor.focus(editor);
+ }, [editor]);
+
+ const renderElement = useCallback((props: RenderElementProps) => {
+ switch (props.element.type) {
+ case 'mention':
+ return } />
+ default:
+ return
+ }
+ }, []);
+
+ const renderLeaf = useCallback((props: RenderLeafProps) => {
+ return
+ }, []);
+
+ const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-repos-selected" | "no-language-model-selected"
+ } | {
+ isSubmitDisabled: false,
+ isSubmitDisabledReason: undefined,
+ } => {
+ if (slateContentToString(editor.children).trim().length === 0) {
+ return {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "empty",
+ }
+ }
+
+ if (isRedirecting) {
+ return {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "redirecting",
+ }
+ }
+
+ if (isGenerating) {
+ return {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "generating",
+ }
+ }
+
+ if (selectedSearchScopes.length === 0) {
+ return {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "no-repos-selected",
+ }
+ }
+
+ if (selectedLanguageModel === undefined) {
+
+ return {
+ isSubmitDisabled: true,
+ isSubmitDisabledReason: "no-language-model-selected",
+ }
+ }
+
+ return {
+ isSubmitDisabled: false,
+ isSubmitDisabledReason: undefined,
+ }
+
+ }, [
+ editor.children,
+ isRedirecting,
+ isGenerating,
+ selectedSearchScopes.length,
+ selectedLanguageModel,
+ ])
+
+ const onSubmit = useCallback(() => {
+ if (isSubmitDisabled) {
+ if (isSubmitDisabledReason === "no-repos-selected") {
+ toast({
+ description: "⚠️ You must select at least one search scope",
+ variant: "destructive",
+ });
+ onContextSelectorOpenChanged(true);
+ }
+
+ return;
+ }
+
+ _onSubmit(editor.children, editor);
+ }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onContextSelectorOpenChanged]);
+
+ const onInsertSuggestion = useCallback((suggestion: Suggestion) => {
+ switch (suggestion.type) {
+ case 'file':
+ insertMention(editor, {
+ type: 'file',
+ path: suggestion.path,
+ repo: suggestion.repo,
+ name: suggestion.name,
+ language: suggestion.language,
+ revision: suggestion.revision,
+ }, range);
+ break;
+ case 'refine': {
+ switch (suggestion.targetSuggestionMode) {
+ case 'file':
+ insertText(editor, 'file:');
+ break;
+ }
+ break;
+ }
+ }
+ ReactEditor.focus(editor);
+ }, [editor, range]);
+
+ const onKeyDown = useCallback((event: KeyboardEvent) => {
+ if (suggestionMode === "none") {
+ switch (event.key) {
+ case 'Enter': {
+ if (event.shiftKey) {
+ break;
+ }
+
+ event.preventDefault();
+ onSubmit();
+ break;
+ }
+ }
+ }
+ else if (suggestions.length > 0) {
+ switch (event.key) {
+ case 'ArrowDown': {
+ event.preventDefault();
+ const prevIndex = index >= suggestions.length - 1 ? 0 : index + 1
+ setIndex(prevIndex)
+ break;
+ }
+ case 'ArrowUp': {
+ event.preventDefault();
+ const nextIndex = index <= 0 ? suggestions.length - 1 : index - 1
+ setIndex(nextIndex)
+ break;
+ }
+ case 'Tab':
+ case 'Enter': {
+ event.preventDefault();
+ const suggestion = suggestions[index];
+ onInsertSuggestion(suggestion);
+ break;
+ }
+ case 'Escape': {
+ event.preventDefault();
+ break;
+ }
+ }
+ }
+ }, [suggestionMode, suggestions, onSubmit, index, onInsertSuggestion]);
+
+ useEffect(() => {
+ if (!range || !suggestionsBoxRef.current) {
+ return;
+ }
+
+ const virtualElement: VirtualElement = {
+ getBoundingClientRect: () => {
+ if (!range) {
+ return new DOMRect();
+ }
+
+ return ReactEditor.toDOMRange(editor, range).getBoundingClientRect();
+ }
+ }
+
+ computePosition(virtualElement, suggestionsBoxRef.current, {
+ placement: preferredSuggestionsBoxPlacement,
+ middleware: [
+ offset(2),
+ flip({
+ mainAxis: true,
+ crossAxis: false,
+ fallbackPlacements: ['top-start', 'bottom-start'],
+ padding: 20,
+ }),
+ shift({
+ padding: 5,
+ })
+ ]
+ }).then(({ x, y }) => {
+ if (suggestionsBoxRef.current) {
+ suggestionsBoxRef.current.style.left = `${x}px`;
+ suggestionsBoxRef.current.style.top = `${y}px`;
+ }
+ })
+ }, [editor, index, range, preferredSuggestionsBoxPlacement]);
+
+ return (
+
+
+
+ {isRedirecting ? (
+
+
+
+ ) :
+ isGenerating ? (
+
+
+ Stop
+
+ ) : (
+
+
+ {
+ // @hack: When submission is disabled, we still want to issue
+ // a warning to the user as to why the submission is disabled.
+ // onSubmit on the Button will not be called because of the
+ // disabled prop, hence the call here.
+ if (isSubmitDisabled) {
+ onSubmit();
+ }
+ }}
+ >
+
+
+
+
+
+ {(isSubmitDisabled && isSubmitDisabledReason === "no-repos-selected") && (
+
+
+
+ You must select at least one search scope
+
+
+ )}
+
+ )}
+
+ {suggestionMode !== "none" && (
+
+ )}
+
+ )
+}
+
+const DefaultElement = (props: RenderElementProps) => {
+ return {props.children}
+}
+
+const Leaf = (props: RenderLeafProps) => {
+ return (
+
+ {props.children}
+
+ )
+}
+
+const MentionComponent = ({
+ attributes,
+ children,
+ element: { data },
+}: RenderElementPropsFor) => {
+ const selected = useSelected();
+ const focused = useFocused();
+
+ if (data.type === 'file') {
+ return (
+
+
+
+
+ {/* @see: https://github.com/ianstormtaylor/slate/issues/3490 */}
+ {IS_MAC ? (
+
+ {children}
+
+ {data.name}
+
+ ) : (
+
+
+ {data.name}
+ {children}
+
+ )}
+
+
+
+
+
+ {data.repo.split('/').pop()} /{data.path}
+
+
+
+ )
+ }
+}
diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx
new file mode 100644
index 00000000..8bb00cef
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
+import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
+import { AtSignIcon } from "lucide-react";
+import { useCallback } from "react";
+import { ReactEditor, useSlate } from "slate-react";
+import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
+import { LanguageModelSelector } from "./languageModelSelector";
+import { SearchScopeSelector } from "./searchScopeSelector";
+import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
+import { AtMentionInfoCard } from "@/features/chat/components/chatBox/atMentionInfoCard";
+
+export interface ChatBoxToolbarProps {
+ languageModels: LanguageModelInfo[];
+ repos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ selectedSearchScopes: SearchScope[];
+ onSelectedSearchScopesChange: (items: SearchScope[]) => void;
+ isContextSelectorOpen: boolean;
+ onContextSelectorOpenChanged: (isOpen: boolean) => void;
+}
+
+export const ChatBoxToolbar = ({
+ languageModels,
+ repos,
+ searchContexts,
+ selectedSearchScopes,
+ onSelectedSearchScopesChange,
+ isContextSelectorOpen,
+ onContextSelectorOpenChanged,
+}: ChatBoxToolbarProps) => {
+ const editor = useSlate();
+
+ const onAddContext = useCallback(() => {
+ editor.insertText("@");
+ ReactEditor.focus(editor);
+ }, [editor]);
+
+ const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
+ initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {languageModels.length > 0 && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ )
+}
diff --git a/packages/web/src/features/chat/components/chatBox/index.ts b/packages/web/src/features/chat/components/chatBox/index.ts
new file mode 100644
index 00000000..94fcf52f
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/index.ts
@@ -0,0 +1 @@
+export { ChatBox } from "./chatBox";
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx b/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx
new file mode 100644
index 00000000..791fa8c8
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { LanguageModelInfo } from "@/features/chat/types";
+import { cn } from "@/lib/utils";
+import {
+ Bot,
+ CheckIcon,
+ ChevronDown,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+import { ModelProviderLogo } from "./modelProviderLogo";
+
+interface LanguageModelSelectorProps {
+ languageModels: LanguageModelInfo[];
+ selectedModel?: LanguageModelInfo;
+ onSelectedModelChange: (model: LanguageModelInfo) => void;
+ className?: string;
+}
+
+export const LanguageModelSelector = ({
+ languageModels: _languageModels,
+ selectedModel,
+ onSelectedModelChange,
+ className,
+}: LanguageModelSelectorProps) => {
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ const handleInputKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (event.key === "Enter") {
+ setIsPopoverOpen(true);
+ }
+ };
+
+ const selectModel = (model: LanguageModelInfo) => {
+ onSelectedModelChange(model);
+ setIsPopoverOpen(false);
+ };
+
+ const handleTogglePopover = () => {
+ setIsPopoverOpen((prev) => !prev);
+ };
+
+ // De-duplicate models
+ const languageModels = useMemo(() => {
+ return _languageModels.filter((model, selfIndex, selfArray) =>
+ selfIndex === selfArray.findIndex((t) => t.model === model.model)
+ );
+ }, [_languageModels]);
+
+ return (
+
+
+
+
+ {selectedModel ? (
+
+ ) : (
+
+ )}
+
+ {selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
+
+
+
+
+
+ setIsPopoverOpen(false)}
+ >
+
+
+
+ No models found.
+
+ {languageModels
+ .map((model, index) => {
+ const isSelected = selectedModel?.model === model.model;
+ return (
+ {
+ selectModel(model)
+ }}
+ className="cursor-pointer"
+ >
+
+
+
+
+ {model.displayName ?? model.model}
+
+ );
+ })}
+
+
+
+
+
+ );
+};
diff --git a/packages/web/src/features/chat/components/chatBox/modelProviderLogo.tsx b/packages/web/src/features/chat/components/chatBox/modelProviderLogo.tsx
new file mode 100644
index 00000000..535cc79d
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/modelProviderLogo.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useMemo } from "react";
+import { LanguageModelProvider } from "../../types";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import anthropicLogo from "@/public/anthropic.svg";
+import azureAiLogo from "@/public/azureai.svg";
+import bedrockLogo from "@/public/bedrock.svg";
+import geminiLogo from "@/public/gemini.svg";
+import openaiLogo from "@/public/openai.svg";
+import deepseekLogo from "@/public/deepseek.svg";
+import mistralLogo from "@/public/mistral.svg";
+import openrouterLogo from "@/public/openrouter.svg";
+import xaiLogo from "@/public/xai.svg";
+import { Box, LucideIcon } from "lucide-react";
+
+interface ModelProviderLogoProps {
+ provider: LanguageModelProvider;
+ className?: string;
+}
+
+export const ModelProviderLogo = ({
+ provider,
+ className,
+}: ModelProviderLogoProps) => {
+ const { src, Icon, className: logoClassName } = useMemo((): { src?: string, Icon?: LucideIcon, className?: string } => {
+ switch (provider) {
+ case 'amazon-bedrock':
+ return {
+ src: bedrockLogo,
+ className: 'dark:invert'
+ };
+ case 'anthropic':
+ return {
+ src: anthropicLogo,
+ className: 'dark:invert'
+ };
+ case 'azure':
+ return {
+ src: azureAiLogo,
+ };
+ case 'deepseek':
+ return {
+ src: deepseekLogo,
+ };
+ case 'openai':
+ return {
+ src: openaiLogo,
+ className: 'dark:invert'
+ };
+ case 'google-generative-ai':
+ case 'google-vertex':
+ return {
+ src: geminiLogo,
+ };
+ case 'google-vertex-anthropic':
+ return {
+ src: anthropicLogo,
+ className: 'dark:invert'
+ };
+ case 'mistral':
+ return {
+ src: mistralLogo,
+ };
+ case 'openrouter':
+ return {
+ src: openrouterLogo,
+ className: 'dark:invert'
+ };
+ case 'xai':
+ return {
+ src: xaiLogo,
+ className: 'dark:invert'
+ };
+ case 'openai-compatible':
+ return {
+ Icon: Box,
+ className: 'text-muted-foreground'
+ };
+ }
+ }, [provider]);
+
+ return src ? (
+
+ ) : Icon ? (
+
+ ) : null;
+}
diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeInfoCard.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeInfoCard.tsx
new file mode 100644
index 00000000..d3d3ade4
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/searchScopeInfoCard.tsx
@@ -0,0 +1,26 @@
+import { BookMarkedIcon, LibraryBigIcon, ScanSearchIcon } from "lucide-react";
+
+export const SearchScopeInfoCard = () => {
+ return (
+
+
+
+
Search Scope
+
+
+ When asking Sourcebot a question, you can select one or more scopes to focus the search.
+ There are two different types of search scopes:
+
+
+
+ Repository : A single repository, indicated by the code host icon.
+
+
+
+ Reposet : A set of repositories, indicated by the library icon.
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx
new file mode 100644
index 00000000..6b5b370d
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx
@@ -0,0 +1,259 @@
+// Adapted from: web/src/components/ui/multi-select.tsx
+
+import * as React from "react";
+import {
+ CheckIcon,
+ ChevronDown,
+ ScanSearchIcon,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
+import { SearchScopeIcon } from "../searchScopeIcon";
+
+interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes {
+ repos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ selectedSearchScopes: SearchScope[];
+ onSelectedSearchScopesChange: (items: SearchScope[]) => void;
+ className?: string;
+ isOpen: boolean;
+ onOpenChanged: (isOpen: boolean) => void;
+}
+
+export const SearchScopeSelector = React.forwardRef<
+ HTMLButtonElement,
+ SearchScopeSelectorProps
+>(
+ (
+ {
+ repos,
+ searchContexts,
+ className,
+ selectedSearchScopes,
+ onSelectedSearchScopesChange,
+ isOpen,
+ onOpenChanged,
+ ...props
+ },
+ ref
+ ) => {
+ const scrollContainerRef = React.useRef(null);
+ const scrollPosition = React.useRef(0);
+ const [hasSearchInput, setHasSearchInput] = React.useState(false);
+
+ const handleInputKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (event.key === "Enter") {
+ onOpenChanged(true);
+ } else if (event.key === "Backspace" && !event.currentTarget.value) {
+ const newSelectedItems = [...selectedSearchScopes];
+ newSelectedItems.pop();
+ onSelectedSearchScopesChange(newSelectedItems);
+ }
+ };
+
+ const toggleItem = (item: SearchScope) => {
+ // Store current scroll position before state update
+ if (scrollContainerRef.current) {
+ scrollPosition.current = scrollContainerRef.current.scrollTop;
+ }
+
+ const isSelected = selectedSearchScopes.some(
+ (selected) => selected.type === item.type && selected.value === item.value
+ );
+
+ const newSelectedItems = isSelected ?
+ selectedSearchScopes.filter(
+ (selected) => !(selected.type === item.type && selected.value === item.value)
+ ) :
+ [...selectedSearchScopes, item];
+
+ onSelectedSearchScopesChange(newSelectedItems);
+ };
+
+ const handleClear = () => {
+ onSelectedSearchScopesChange([]);
+ };
+
+ const handleSelectAll = () => {
+ onSelectedSearchScopesChange(allSearchScopeItems);
+ };
+
+ const handleTogglePopover = () => {
+ onOpenChanged(!isOpen);
+ };
+
+ const allSearchScopeItems = React.useMemo(() => {
+ const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
+ type: 'reposet' as const,
+ value: context.name,
+ name: context.name,
+ repoCount: context.repoNames.length
+ }));
+
+ const repoSearchScopeItems: RepoSearchScope[] = repos.map(repo => ({
+ type: 'repo' as const,
+ value: repo.repoName,
+ name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName,
+ codeHostType: repo.codeHostType,
+ }));
+
+ return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
+ }, [repos, searchContexts]);
+
+ const sortedSearchScopeItems = React.useMemo(() => {
+ return allSearchScopeItems
+ .map((item) => ({
+ item,
+ isSelected: selectedSearchScopes.some(
+ (selected) => selected.type === item.type && selected.value === item.value
+ )
+ }))
+ .sort((a, b) => {
+ // Selected items first
+ if (a.isSelected && !b.isSelected) return -1;
+ if (!a.isSelected && b.isSelected) return 1;
+ // Then reposets before repos
+ if (a.item.type === 'reposet' && b.item.type === 'repo') return -1;
+ if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
+ return 0;
+ })
+ }, [allSearchScopeItems, selectedSearchScopes]);
+
+ // Restore scroll position after re-render
+ React.useEffect(() => {
+ if (scrollContainerRef.current && scrollPosition.current > 0) {
+ scrollContainerRef.current.scrollTop = scrollPosition.current;
+ }
+ }, [sortedSearchScopeItems]);
+
+ return (
+
+
+
+
+
+
+ {
+ selectedSearchScopes.length === 0 ? `Search scopes` :
+ selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
+ `${selectedSearchScopes.length} selected`
+ }
+
+
+
+
+
+ onOpenChanged(false)}
+ >
+
+ setHasSearchInput(!!value)}
+ />
+
+ No results found.
+
+ {!hasSearchInput && (
+
+ Select all
+
+ )}
+ {sortedSearchScopeItems.map(({ item, isSelected }) => {
+ return (
+ toggleItem(item)}
+ className="cursor-pointer"
+ >
+
+
+
+
+
+
+
+
+ {item.name}
+
+ {item.type === 'reposet' && (
+
+ {item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ {selectedSearchScopes.length > 0 && (
+ <>
+
+
+ Clear
+
+ >
+ )}
+
+
+
+ );
+ }
+);
+
+SearchScopeSelector.displayName = "SearchScopeSelector";
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/suggestionsBox.tsx b/packages/web/src/features/chat/components/chatBox/suggestionsBox.tsx
new file mode 100644
index 00000000..a636c10e
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/suggestionsBox.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+import { forwardRef, useMemo } from "react";
+import { createPortal } from "react-dom";
+import { VscFiles } from "react-icons/vsc";
+import { FileSuggestion, RefineSuggestion, Suggestion } from "./types";
+
+interface SuggestionBoxProps {
+ selectedIndex: number;
+ onInsertSuggestion: (suggestion: Suggestion) => void;
+ isLoading: boolean;
+ suggestions: Suggestion[];
+}
+
+export const SuggestionBox = forwardRef(({
+ selectedIndex,
+ onInsertSuggestion,
+ isLoading,
+ suggestions,
+}, ref) => {
+
+ return createPortal(
+
+ {isLoading ? (
+
+ {
+ Array.from({ length: 10 }).map((_, index) => (
+
+ ))
+ }
+
+ ) :
+ (suggestions.length === 0) ? (
+
+ ) :
+ (
+
+ {suggestions.map((suggestion, i) => (
+
{
+ onInsertSuggestion(suggestion);
+ }}
+ >
+ {
+ suggestion.type === 'file' && (
+
+ )
+ }
+ {
+ suggestion.type === 'refine' && (
+
+ )
+ }
+
+ ))}
+
+ )}
+
,
+ document.body
+ )
+});
+
+SuggestionBox.displayName = 'SuggestionBox';
+
+
+const FileSuggestionListItem = ({ file }: { file: FileSuggestion }) => {
+ return (
+ <>
+
+
+
+ {file.name}
+
+
+ {file.repo.split('/').pop()} /{file.path}
+
+
+ >
+ )
+}
+
+const RefineSuggestionListItem = ({ refine }: { refine: RefineSuggestion }) => {
+
+ const Icon = useMemo(() => {
+ switch (refine.targetSuggestionMode) {
+ case 'file':
+ return VscFiles;
+ }
+ }, [refine.targetSuggestionMode]);
+
+ return (
+ <>
+
+
+
+ {refine.name}
+
+
+ {refine.description}
+
+
+ >
+ )
+}
diff --git a/packages/web/src/features/chat/components/chatBox/types.ts b/packages/web/src/features/chat/components/chatBox/types.ts
new file mode 100644
index 00000000..b7f92ba1
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/types.ts
@@ -0,0 +1,24 @@
+
+export type SuggestionMode =
+ "none" |
+ "refine" |
+ "file"
+;
+
+export type RefineSuggestion = {
+ type: 'refine';
+ targetSuggestionMode: Exclude;
+ name: string;
+ description: string;
+}
+
+export type FileSuggestion = {
+ type: 'file';
+ repo: string;
+ path: string;
+ name: string;
+ language: string;
+ revision: string;
+}
+
+export type Suggestion = FileSuggestion | RefineSuggestion;
diff --git a/packages/web/src/features/chat/components/chatBox/useSuggestionModeAndQuery.ts b/packages/web/src/features/chat/components/chatBox/useSuggestionModeAndQuery.ts
new file mode 100644
index 00000000..595e9fc4
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/useSuggestionModeAndQuery.ts
@@ -0,0 +1,98 @@
+'use client';
+
+import { word } from "@/features/chat/utils";
+import { useEffect, useMemo } from "react";
+import { Editor, Range } from "slate";
+import { SuggestionMode } from "./types";
+import { useSlate, useSlateSelection } from "slate-react";
+import { usePrevious } from "@uidotdev/usehooks";
+
+
+export const useSuggestionModeAndQuery = () => {
+ const selection = useSlateSelection();
+ const editor = useSlate();
+
+ const { suggestionQuery, suggestionMode, range } = useMemo<{
+ suggestionQuery: string;
+ suggestionMode: SuggestionMode;
+ range: Range | null;
+ }>(() => {
+ if (!selection || !Range.isCollapsed(selection)) {
+ return {
+ suggestionMode: "none",
+ suggestionQuery: '',
+ range: null,
+ };
+ }
+
+ const range = word(editor, selection, {
+ terminator: [' '],
+ directions: 'both',
+ });
+
+ if (!range) {
+ return {
+ suggestionMode: "none",
+ suggestionQuery: '',
+ range: null,
+ };
+ }
+
+ const text = Editor.string(editor, range);
+
+ let match: RegExpMatchArray | null = null;
+
+ // Refine mode.
+ match = text.match(/^@$/);
+ if (match) {
+ return {
+ suggestionMode: "refine",
+ suggestionQuery: '',
+ range,
+ };
+ }
+
+ // File mode.
+ match = text.match(/^@file:(.*)$/);
+ if (match) {
+ return {
+ suggestionMode: "file",
+ suggestionQuery: match[1],
+ range,
+ };
+ }
+
+ // If the user starts typing, fallback to file mode.
+ // In the future, it would be nice to have a "all" mode that
+ // searches across all mode types.
+ match = text.match(/^@(.*)$/);
+ if (match) {
+ return {
+ suggestionMode: "file",
+ suggestionQuery: match[1],
+ range,
+ };
+ }
+
+ // Default to none mode.
+ return {
+ suggestionMode: "none",
+ suggestionQuery: '',
+ range: null,
+ };
+ }, [editor, selection]);
+
+ // Debug logging.
+ const previousSuggestionMode = usePrevious(suggestionMode);
+ useEffect(() => {
+ if (previousSuggestionMode !== suggestionMode) {
+ console.debug(`Suggestion mode changed: ${previousSuggestionMode} -> ${suggestionMode}`);
+ }
+ }, [previousSuggestionMode, suggestionMode])
+
+ return {
+ suggestionQuery,
+ suggestionMode,
+ range,
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatBox/useSuggestionsData.ts b/packages/web/src/features/chat/components/chatBox/useSuggestionsData.ts
new file mode 100644
index 00000000..59f52b0c
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatBox/useSuggestionsData.ts
@@ -0,0 +1,82 @@
+'use client';
+
+import { useQuery } from "@tanstack/react-query";
+import { FileSuggestion, RefineSuggestion, Suggestion, SuggestionMode } from "./types";
+import { useDomain } from "@/hooks/useDomain";
+import { unwrapServiceError } from "@/lib/utils";
+import { search } from "@/app/api/(client)/client";
+import { useMemo } from "react";
+
+interface Props {
+ suggestionMode: SuggestionMode;
+ suggestionQuery: string;
+ selectedRepos: string[];
+}
+
+const refineSuggestions: RefineSuggestion[] = [
+ {
+ type: 'refine',
+ targetSuggestionMode: 'file',
+ name: 'Files',
+ description: 'Include a file in the agent\'s context window.',
+ }
+]
+
+export const useSuggestionsData = ({
+ suggestionMode,
+ suggestionQuery,
+ selectedRepos,
+}: Props): { isLoading: boolean, suggestions: Suggestion[] } => {
+ const domain = useDomain();
+
+ const { data: fileSuggestions, isLoading: _isLoadingFileSuggestions } = useQuery({
+ queryKey: ["fileSuggestions-agentic", suggestionQuery, domain, selectedRepos],
+ queryFn: () => {
+ let query = `file:${suggestionQuery}`;
+ if (selectedRepos.length > 0) {
+ query += ` reposet:${selectedRepos.join(',')}`;
+ }
+
+ return unwrapServiceError(search({
+ query,
+ matches: 10,
+ contextLines: 1,
+ }, domain))
+ },
+ select: (data): FileSuggestion[] => {
+ return data.files.map((file) => {
+ const path = file.fileName.text;
+ const suggestion: FileSuggestion = {
+ type: 'file',
+ path,
+ repo: file.repository,
+ name: path.split('/').pop() ?? '',
+ language: file.language,
+ revision: 'HEAD', // @todo: make revision configurable.
+ }
+
+ return suggestion;
+ });
+ },
+ enabled: suggestionMode === "file",
+ });
+ const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFileSuggestions, [_isLoadingFileSuggestions, suggestionMode]);
+
+ switch (suggestionMode) {
+ case 'file':
+ return {
+ suggestions: fileSuggestions ?? [],
+ isLoading: isLoadingFiles,
+ }
+ case 'refine':
+ return {
+ suggestions: refineSuggestions,
+ isLoading: false,
+ }
+ default:
+ return {
+ isLoading: false,
+ suggestions: [],
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/answerCard.tsx b/packages/web/src/features/chat/components/chatThread/answerCard.tsx
new file mode 100644
index 00000000..d37ee67e
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/answerCard.tsx
@@ -0,0 +1,181 @@
+'use client';
+
+import { useExtractTOCItems } from "../../useTOCItems";
+import { TableOfContents } from "./tableOfContents";
+import { Button } from "@/components/ui/button";
+import { TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
+import { Separator } from "@/components/ui/separator";
+import { MarkdownRenderer } from "./markdownRenderer";
+import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
+import { Toggle } from "@/components/ui/toggle";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { CopyIconButton } from "@/app/[domain]/components/copyIconButton";
+import { useToast } from "@/components/hooks/use-toast";
+import { convertLLMOutputToPortableMarkdown } from "../../utils";
+import { submitFeedback } from "../../actions";
+import { isServiceError } from "@/lib/utils";
+import { useDomain } from "@/hooks/useDomain";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { LangfuseWeb } from "langfuse";
+import { env } from "@/env.mjs";
+
+interface AnswerCardProps {
+ answerText: string;
+ messageId: string;
+ chatId: string;
+ traceId?: string;
+}
+
+const langfuseWeb = (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY) ? new LangfuseWeb({
+ publicKey: env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
+ baseUrl: env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
+}) : null;
+
+export const AnswerCard = forwardRef(({
+ answerText,
+ messageId,
+ chatId,
+ traceId,
+}, forwardedRef) => {
+ const markdownRendererRef = useRef(null);
+ const { tocItems, activeId } = useExtractTOCItems({ target: markdownRendererRef.current });
+ const [isTOCButtonToggled, setIsTOCButtonToggled] = useState(false);
+ const { toast } = useToast();
+ const domain = useDomain();
+ const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
+ const [feedback, setFeedback] = useState<'like' | 'dislike' | undefined>(undefined);
+ const captureEvent = useCaptureEvent();
+
+ useImperativeHandle(
+ forwardedRef,
+ () => markdownRendererRef.current as HTMLDivElement
+ );
+
+ const onCopyAnswer = useCallback(() => {
+ const markdownText = convertLLMOutputToPortableMarkdown(answerText);
+ navigator.clipboard.writeText(markdownText);
+ toast({
+ description: "✅ Copied to clipboard",
+ });
+ return true;
+ }, [answerText, toast]);
+
+ const onFeedback = useCallback(async (feedbackType: 'like' | 'dislike') => {
+ setIsSubmittingFeedback(true);
+
+ const response = await submitFeedback({
+ chatId,
+ messageId,
+ feedbackType
+ }, domain);
+
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to submit feedback: ${response.message}`,
+ variant: "destructive"
+ });
+ } else {
+ toast({
+ description: `✅ Feedback submitted`,
+ });
+ setFeedback(feedbackType);
+ captureEvent('wa_chat_feedback_submitted', {
+ feedback: feedbackType,
+ chatId,
+ messageId,
+ });
+
+ langfuseWeb?.score({
+ traceId: traceId,
+ name: 'user_feedback',
+ value: feedbackType === 'like' ? 1 : 0,
+ })
+ }
+
+ setIsSubmittingFeedback(false);
+ }, [chatId, messageId, domain, toast, captureEvent, traceId]);
+
+ return (
+
+ {(isTOCButtonToggled && tocItems.length > 0) && (
+
+ )}
+
+
+
+
Answer
+
+
+
+
+
+
+ Copy answer
+
+
+ {tocItems.length > 0 && (
+
+
+
+
+
+
+
+ Toggle table of contents
+
+
+ )}
+
+
+
+
+
+
+
+ onFeedback('like')}
+ disabled={isSubmittingFeedback || feedback !== undefined}
+ >
+
+
+ onFeedback('dislike')}
+ disabled={isSubmittingFeedback || feedback !== undefined}
+ >
+
+
+
+
+
+
+ )
+})
+
+AnswerCard.displayName = 'AnswerCard';
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx
new file mode 100644
index 00000000..0c58ae67
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx
@@ -0,0 +1,387 @@
+'use client';
+
+import { useToast } from '@/components/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Separator } from '@/components/ui/separator';
+import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
+import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
+import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils';
+import { useDomain } from '@/hooks/useDomain';
+import { useChat } from '@ai-sdk/react';
+import { CreateUIMessage, DefaultChatTransport } from 'ai';
+import { ArrowDownIcon } from 'lucide-react';
+import { useNavigationGuard } from 'next-navigation-guard';
+import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
+import { Descendant } from 'slate';
+import { useMessagePairs } from '../../useMessagePairs';
+import { useSelectedLanguageModel } from '../../useSelectedLanguageModel';
+import { ChatBox } from '../chatBox';
+import { ChatBoxToolbar } from '../chatBox/chatBoxToolbar';
+import { ChatThreadListItem } from './chatThreadListItem';
+import { ErrorBanner } from './errorBanner';
+import { useRouter } from 'next/navigation';
+import { usePrevious } from '@uidotdev/usehooks';
+import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
+import { generateAndUpdateChatNameFromMessage } from '../../actions';
+import { isServiceError } from '@/lib/utils';
+
+type ChatHistoryState = {
+ scrollOffset?: number;
+}
+
+interface ChatThreadProps {
+ id?: string | undefined;
+ initialMessages?: SBChatMessage[];
+ inputMessage?: CreateUIMessage;
+ languageModels: LanguageModelInfo[];
+ repos: RepositoryQuery[];
+ searchContexts: SearchContextQuery[];
+ selectedSearchScopes: SearchScope[];
+ onSelectedSearchScopesChange: (items: SearchScope[]) => void;
+ isChatReadonly: boolean;
+}
+
+export const ChatThread = ({
+ id: defaultChatId,
+ initialMessages,
+ inputMessage,
+ languageModels,
+ repos,
+ searchContexts,
+ selectedSearchScopes,
+ onSelectedSearchScopesChange,
+ isChatReadonly,
+}: ChatThreadProps) => {
+ const domain = useDomain();
+ const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false);
+ const scrollAreaRef = useRef(null);
+ const latestMessagePairRef = useRef(null);
+ const hasSubmittedInputMessage = useRef(false);
+ const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+ const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
+
+ // Initial state is from attachments that exist in in the chat history.
+ const [sources, setSources] = useState(
+ initialMessages?.flatMap((message) =>
+ message.parts
+ .filter((part) => part.type === 'data-source')
+ .map((part) => part.data)
+ ) ?? []
+ );
+
+ const { selectedLanguageModel } = useSelectedLanguageModel({
+ initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
+ });
+
+ const {
+ messages,
+ sendMessage: _sendMessage,
+ error,
+ status,
+ stop,
+ id: chatId,
+ } = useChat({
+ id: defaultChatId,
+ messages: initialMessages,
+ transport: new DefaultChatTransport({
+ api: '/api/chat',
+ headers: {
+ "X-Org-Domain": domain,
+ }
+ }),
+ onData: (dataPart) => {
+ // Keeps sources added by the assistant in sync.
+ if (dataPart.type === 'data-source') {
+ setSources((prev) => [...prev, dataPart.data]);
+ }
+ }
+ });
+
+ const sendMessage = useCallback((message: CreateUIMessage) => {
+ if (!selectedLanguageModel) {
+ toast({
+ description: "Failed to send message. No language model selected.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // Keeps sources added by the user in sync.
+ const sources = message.parts
+ .filter((part) => part.type === 'data-source')
+ .map((part) => part.data);
+ setSources((prev) => [...prev, ...sources]);
+
+ _sendMessage(message, {
+ body: {
+ selectedSearchScopes,
+ languageModelId: selectedLanguageModel.model,
+ } satisfies AdditionalChatRequestParams,
+ });
+
+ if (
+ messages.length === 0 &&
+ message.parts.length > 0 &&
+ message.parts[0].type === 'text'
+ ) {
+ generateAndUpdateChatNameFromMessage(
+ {
+ chatId,
+ languageModelId: selectedLanguageModel.model,
+ message: message.parts[0].text,
+ },
+ domain
+ ).then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to generate chat name. Reason: ${response.message}`,
+ variant: "destructive",
+ });
+ }
+ // Refresh the page to update the chat name.
+ router.refresh();
+ });
+ }
+ }, [
+ selectedLanguageModel,
+ _sendMessage,
+ selectedSearchScopes,
+ messages.length,
+ toast,
+ chatId,
+ domain,
+ router,
+ ]);
+
+
+ const messagePairs = useMessagePairs(messages);
+
+ useNavigationGuard({
+ enabled: ({ type }) => {
+ // @note: a "refresh" in this context means we have triggered a client side
+ // refresh via `router.refresh()`, and not the user pressing "CMD+R"
+ // (that would be a "beforeunload" event). We can safely peform refreshes
+ // without loosing any unsaved changes.
+ if (type === "refresh") {
+ return false;
+ }
+
+ return status === "streaming" || status === "submitted";
+ },
+ confirm: () => window.confirm("You have unsaved changes that will be lost."),
+ });
+
+ // When the chat is finished, refresh the page to update the chat history.
+ const prevStatus = usePrevious(status);
+ useEffect(() => {
+ const wasPending = prevStatus === "submitted" || prevStatus === "streaming";
+ const isFinished = status === "error" || status === "ready";
+
+ if (wasPending && isFinished) {
+ router.refresh();
+ }
+ }, [prevStatus, status, router]);
+
+ useEffect(() => {
+ if (!inputMessage || hasSubmittedInputMessage.current) {
+ return;
+ }
+
+ sendMessage(inputMessage);
+ setIsAutoScrollEnabled(true);
+ hasSubmittedInputMessage.current = true;
+ }, [inputMessage, sendMessage]);
+
+ // Track scroll position changes.
+ useEffect(() => {
+ const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
+ if (!scrollElement) return;
+
+ let timeout: NodeJS.Timeout | null = null;
+
+ const handleScroll = () => {
+ const scrollOffset = scrollElement.scrollTop;
+
+ const threshold = 50; // pixels from bottom to consider "at bottom"
+ const { scrollHeight, clientHeight } = scrollElement;
+ const isAtBottom = scrollHeight - scrollOffset - clientHeight <= threshold;
+ setIsAutoScrollEnabled(isAtBottom);
+
+ // Debounce the history state update
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+
+ timeout = setTimeout(() => {
+ history.replaceState(
+ {
+ scrollOffset,
+ } satisfies ChatHistoryState,
+ '',
+ window.location.href
+ );
+ }, 300);
+ };
+
+ scrollElement.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ scrollElement.removeEventListener('scroll', handleScroll);
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
+ if (!scrollElement) {
+ return;
+ }
+
+ const { scrollOffset } = (history.state ?? {}) as ChatHistoryState;
+ scrollElement.scrollTo({
+ top: scrollOffset ?? 0,
+ behavior: 'instant',
+ });
+ }, []);
+
+ // When messages are being streamed, scroll to the latest message
+ // assuming auto scrolling is enabled.
+ useEffect(() => {
+ if (
+ !latestMessagePairRef.current ||
+ !isAutoScrollEnabled ||
+ messages.length === 0
+ ) {
+ return;
+ }
+
+ latestMessagePairRef.current.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ inline: 'nearest',
+ });
+
+ }, [isAutoScrollEnabled, messages]);
+
+
+ // Keep the error state & banner visibility in sync.
+ useEffect(() => {
+ if (error) {
+ setIsErrorBannerVisible(true);
+ }
+ }, [error]);
+
+ const onSubmit = useCallback((children: Descendant[], editor: CustomEditor) => {
+ const text = slateContentToString(children);
+ const mentions = getAllMentionElements(children);
+
+ const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes);
+ sendMessage(message);
+
+ setIsAutoScrollEnabled(true);
+
+ resetEditor(editor);
+ }, [sendMessage, selectedSearchScopes]);
+
+ return (
+ <>
+ {error && (
+ setIsErrorBannerVisible(false)}
+ />
+ )}
+
+
+ {
+ messagePairs.length === 0 ? (
+
+ ) : (
+ <>
+ {messagePairs.map(([userMessage, assistantMessage], index) => {
+ const isLastPair = index === messagePairs.length - 1;
+ const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
+
+ return (
+
+
+ {index !== messagePairs.length - 1 && (
+
+ )}
+
+ );
+ })}
+ >
+ )
+ }
+ {
+ (!isAutoScrollEnabled && status === "streaming") && (
+
+
{
+ latestMessagePairRef.current?.scrollIntoView({
+ behavior: 'instant',
+ block: 'end',
+ inline: 'nearest',
+ });
+ }}
+ >
+
+
+
+ )
+ }
+
+ {!isChatReadonly && (
+
+ )}
+ >
+ );
+}
diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx
new file mode 100644
index 00000000..3905072e
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx
@@ -0,0 +1,413 @@
+'use client';
+
+import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
+import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
+import { Skeleton } from '@/components/ui/skeleton';
+import { CheckCircle, Loader2 } from 'lucide-react';
+import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import scrollIntoView from 'scroll-into-view-if-needed';
+import { Reference, referenceSchema, SBChatMessage, Source } from "../../types";
+import { useExtractReferences } from '../../useExtractReferences';
+import { getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from '../../utils';
+import { AnswerCard } from './answerCard';
+import { DetailsCard } from './detailsCard';
+import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer';
+import { ReferencedSourcesListView } from './referencedSourcesListView';
+import { uiVisiblePartTypes } from '../../constants';
+
+interface ChatThreadListItemProps {
+ userMessage: SBChatMessage;
+ assistantMessage?: SBChatMessage;
+ isStreaming: boolean;
+ sources: Source[];
+ chatId: string;
+ index: number;
+}
+
+export const ChatThreadListItem = forwardRef(({
+ userMessage,
+ assistantMessage: _assistantMessage,
+ isStreaming,
+ sources,
+ chatId,
+ index,
+}, ref) => {
+ const leftPanelRef = useRef(null);
+ const [leftPanelHeight, setLeftPanelHeight] = useState(null);
+ const answerRef = useRef(null);
+
+ const [hoveredReference, setHoveredReference] = useState(undefined);
+ const [selectedReference, setSelectedReference] = useState(undefined);
+ const [isDetailsPanelExpanded, _setIsDetailsPanelExpanded] = useState(isStreaming);
+ const hasAutoCollapsed = useRef(false);
+ const userHasManuallyExpanded = useRef(false);
+
+ const userQuestion = useMemo(() => {
+ return userMessage.parts.length > 0 && userMessage.parts[0].type === 'text' ? userMessage.parts[0].text : '';
+ }, [userMessage]);
+
+ // Take the assistant message and repair any references that are not properly formatted.
+ // This applies to parts that are text (i.e., text & reasoning).
+ const assistantMessage = useMemo(() => {
+ if (!_assistantMessage) {
+ return undefined;
+ }
+
+ return {
+ ..._assistantMessage,
+ ...(_assistantMessage.parts ? {
+ parts: _assistantMessage.parts.map(part => {
+ switch (part.type) {
+ case 'text':
+ case 'reasoning':
+ return {
+ ...part,
+ text: repairReferences(part.text),
+ }
+ default:
+ return part;
+ }
+ }),
+ } : {}),
+ } satisfies SBChatMessage;
+ }, [_assistantMessage]);
+
+ const answerPart = useMemo(() => {
+ if (!assistantMessage) {
+ return undefined;
+ }
+
+ return getAnswerPartFromAssistantMessage(assistantMessage, isStreaming);
+ }, [assistantMessage, isStreaming]);
+
+ const references = useExtractReferences(answerPart);
+
+ // Groups parts into steps that are associated with thinking steps that
+ // should be visible to the user. By "steps", we mean parts that originated
+ // from the same LLM invocation. By "visibile", we mean parts that have some
+ // visual representation in the UI (e.g., text, reasoning, tool calls, etc.).
+ const uiVisibleThinkingSteps = useMemo(() => {
+ const steps = groupMessageIntoSteps(assistantMessage?.parts ?? []);
+
+ // Filter out the answerPart and empty steps
+ return steps
+ .map(
+ (step) => step
+ // First, filter out any parts that are not text
+ .filter((part) => {
+ if (part.type !== 'text') {
+ return true;
+ }
+
+ return part.text !== answerPart?.text;
+ })
+ .filter((part) => {
+ return uiVisiblePartTypes.includes(part.type);
+ })
+ )
+ // Then, filter out any steps that are empty
+ .filter(step => step.length > 0);
+ }, [answerPart, assistantMessage?.parts]);
+
+ // "thinking" is when the agent is generating output that is not the answer.
+ const isThinking = useMemo(() => {
+ return isStreaming && !answerPart
+ }, [answerPart, isStreaming]);
+
+
+ // Auto-collapse when answer first appears, but only once and respect user preference
+ useEffect(() => {
+ if (answerPart && !hasAutoCollapsed.current && !userHasManuallyExpanded.current) {
+ _setIsDetailsPanelExpanded(false);
+ hasAutoCollapsed.current = true;
+ }
+ }, [answerPart]);
+
+ const onExpandDetailsPanel = useCallback((expanded: boolean) => {
+ _setIsDetailsPanelExpanded(expanded);
+ // If user manually expands after auto-collapse, remember their preference
+ if (expanded && hasAutoCollapsed.current) {
+ userHasManuallyExpanded.current = true;
+ }
+ }, []);
+
+
+ // Measure answer content height for dynamic sizing
+ useEffect(() => {
+ if (!leftPanelRef.current || !answerPart) {
+ setLeftPanelHeight(null);
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ setLeftPanelHeight(entry.contentRect.height);
+ }
+ });
+
+ resizeObserver.observe(leftPanelRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [answerPart]);
+
+ const rightPanelStyle: CSSProperties = useMemo(() => {
+ const maxHeight = 'calc(100vh - 215px)';
+
+ return {
+ height: leftPanelHeight ? `min(${leftPanelHeight}px, ${maxHeight})` : maxHeight,
+ };
+ }, [leftPanelHeight]);
+
+ // Handles mouse over and click events on reference elements, syncing these events
+ // with the `hoveredReference` and `selectedReference` state.
+ useEffect(() => {
+ if (!answerRef.current) {
+ return;
+ }
+
+ const markdownRenderer = answerRef.current;
+
+ const handleMouseOver = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.hasAttribute(REFERENCE_PAYLOAD_ATTRIBUTE)) {
+ try {
+ const jsonPayload = JSON.parse(decodeURIComponent(target.getAttribute(REFERENCE_PAYLOAD_ATTRIBUTE) ?? '{}'));
+ const reference = referenceSchema.parse(jsonPayload);
+ setHoveredReference(reference);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ };
+
+ const handleMouseOut = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.hasAttribute(REFERENCE_PAYLOAD_ATTRIBUTE)) {
+ setHoveredReference(undefined);
+ }
+ };
+
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.hasAttribute(REFERENCE_PAYLOAD_ATTRIBUTE)) {
+ try {
+ const jsonPayload = JSON.parse(decodeURIComponent(target.getAttribute(REFERENCE_PAYLOAD_ATTRIBUTE) ?? '{}'));
+ const reference = referenceSchema.parse(jsonPayload);
+ setSelectedReference(reference.id === selectedReference?.id ? undefined : reference);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ };
+
+ markdownRenderer.addEventListener('mouseover', handleMouseOver);
+ markdownRenderer.addEventListener('mouseout', handleMouseOut);
+ markdownRenderer.addEventListener('click', handleClick);
+
+ return () => {
+ markdownRenderer.removeEventListener('mouseover', handleMouseOver);
+ markdownRenderer.removeEventListener('mouseout', handleMouseOut);
+ markdownRenderer.removeEventListener('click', handleClick);
+ };
+ }, [answerPart, selectedReference?.id]); // Re-run when answerPart changes to ensure we catch new content
+
+ // When the selected reference changes, highlight all associated reference elements
+ // and scroll to the nearest one, if needed.
+ useEffect(() => {
+ if (!selectedReference) {
+ return;
+ }
+
+ // The reference id is attached to the DOM element as a class name.
+ // @see: markdownRenderer.tsx
+ const referenceElements = Array.from(answerRef.current?.getElementsByClassName(selectedReference.id) ?? []);
+ if (referenceElements.length === 0) {
+ return;
+ }
+
+ const nearestReferenceElement = getNearestReferenceElement(referenceElements);
+ scrollIntoView(nearestReferenceElement, {
+ behavior: 'smooth',
+ scrollMode: 'if-needed',
+ block: 'center',
+ });
+
+ referenceElements.forEach(element => {
+ // Check if it's an inline chat citation or code viewer element
+ const isInlineCitation = element.classList.contains('bg-chat-citation');
+ const selectedClass = isInlineCitation ? 'chat-citation--selected' : 'chat-reference--selected';
+ element.classList.add(selectedClass);
+ });
+
+ return () => {
+ referenceElements.forEach(element => {
+ // Remove both possible selected classes
+ element.classList.remove('chat-reference--selected');
+ element.classList.remove('chat-citation--selected');
+ });
+ };
+ }, [selectedReference]);
+
+ // When the hovered reference changes, highlight all associated reference elements.
+ useEffect(() => {
+ if (!hoveredReference) {
+ return;
+ }
+
+ // The reference id is attached to the DOM element as a class name.
+ // @see: markdownRenderer.tsx
+ const referenceElements = Array.from(answerRef.current?.getElementsByClassName(hoveredReference.id) ?? []);
+ if (referenceElements.length === 0) {
+ return;
+ }
+
+ referenceElements.forEach(element => {
+ // Check if it's an inline chat citation or code viewer element
+ const isInlineCitation = element.classList.contains('bg-chat-citation');
+ const hoverClass = isInlineCitation ? 'chat-citation--hover' : 'chat-reference--hover';
+ element.classList.add(hoverClass);
+ });
+
+ return () => {
+ referenceElements.forEach(element => {
+ // Remove both possible hover classes
+ element.classList.remove('chat-reference--hover');
+ element.classList.remove('chat-citation--hover');
+ });
+ };
+ }, [hoveredReference]);
+
+
+ return (
+
+
+
+
+
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isThinking && (
+
+ )}
+
+
+
+ {(answerPart && assistantMessage) ? (
+
+ ) : !isStreaming && (
+
Error: No answer response was provided
+ )}
+
+
+
+
+
+ {references.length > 0 ? (
+
+ ) : isStreaming ? (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ ) : (
+
+ No file references found
+
+ )}
+
+
+
+
+ )
+});
+
+ChatThreadListItem.displayName = 'ChatThreadListItem';
+
+// Finds the nearest reference element to the viewport center.
+const getNearestReferenceElement = (referenceElements: Element[]) => {
+ return referenceElements.reduce((nearest, current) => {
+ if (!nearest) return current;
+
+ const nearestRect = nearest.getBoundingClientRect();
+ const currentRect = current.getBoundingClientRect();
+
+ // Calculate distance from element center to viewport center
+ const viewportCenter = window.innerHeight / 2;
+ const nearestDistance = Math.abs((nearestRect.top + nearestRect.bottom) / 2 - viewportCenter);
+ const currentDistance = Math.abs((currentRect.top + currentRect.bottom) / 2 - viewportCenter);
+
+ return currentDistance < nearestDistance ? current : nearest;
+ });
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/codeBlock.tsx b/packages/web/src/features/chat/components/chatThread/codeBlock.tsx
new file mode 100644
index 00000000..ce9aacd6
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/codeBlock.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { LightweightCodeHighlighter } from '@/app/[domain]/components/lightweightCodeHighlighter';
+import { cn } from '@/lib/utils';
+import { DoubleArrowDownIcon, DoubleArrowUpIcon } from '@radix-ui/react-icons';
+import { useMemo, useState } from 'react';
+
+interface CodeBlockComponentProps {
+ code: string;
+ language?: string;
+}
+
+const MAX_LINES_TO_DISPLAY = 14;
+
+export const CodeBlock = ({
+ code,
+ language = "text",
+}: CodeBlockComponentProps) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const lineCount = useMemo(() => {
+ return code.split('\n').length;
+ }, [code]);
+
+ const isExpandButtonVisible = useMemo(() => {
+ return lineCount > MAX_LINES_TO_DISPLAY;
+ }, [lineCount]);
+
+ return (
+
+
+
+ {code}
+
+
+ {isExpandButtonVisible && (
+
setIsExpanded(!isExpanded)}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter") {
+ return;
+ }
+ setIsExpanded(!isExpanded);
+ }}
+ >
+ {isExpanded ? : }
+ {isExpanded ? 'Show less' : 'Show more'}
+
+ )}
+
+ );
+};
diff --git a/packages/web/src/features/chat/components/chatThread/codeFoldingExpandButton.tsx b/packages/web/src/features/chat/components/chatThread/codeFoldingExpandButton.tsx
new file mode 100644
index 00000000..15519ed1
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/codeFoldingExpandButton.tsx
@@ -0,0 +1,57 @@
+import { useCallback, useMemo } from "react";
+import { PiArrowsOutLineVertical, PiArrowLineUp, PiArrowLineDown } from "react-icons/pi";
+
+interface CodeFoldingExpandButton {
+ onExpand: (direction: 'up' | 'down') => void;
+ hiddenLineCount: number;
+ canExpandUp: boolean;
+ canExpandDown: boolean;
+}
+
+export const CodeFoldingExpandButton = ({
+ onExpand,
+ hiddenLineCount,
+ canExpandUp,
+ canExpandDown,
+}: CodeFoldingExpandButton) => {
+
+ const expandDirections = useMemo((): ('up' | 'down' | 'merged')[] => {
+ if (canExpandUp && !canExpandDown) {
+ return ['up'];
+ }
+
+ if (!canExpandUp && canExpandDown) {
+ return ['down'];
+ }
+
+ if (hiddenLineCount < 20) {
+ return ['merged'];
+ }
+
+ return ['down', 'up'];
+ }, [canExpandUp, canExpandDown, hiddenLineCount]);
+
+ const onClick = useCallback((direction: 'up' | 'down' | 'merged') => {
+ if (direction === 'merged') {
+ // default to expanding down
+ onExpand('down');
+ } else {
+ onExpand(direction);
+ }
+ }, [onExpand]);
+
+ return (
+ <>
+ {expandDirections.map((direction, index) => (
+ onClick(direction)}
+ >
+ {direction === 'up' &&
}
+ {direction === 'down' &&
}
+ {direction === 'merged' &&
}
+
+ ))}
+ >
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.test.ts b/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.test.ts
new file mode 100644
index 00000000..50b73512
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.test.ts
@@ -0,0 +1,720 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { expect, test, describe } from 'vitest'
+import {
+ calculateVisibleRanges,
+ calculateHiddenRegions,
+ createCodeFoldingExtension,
+ updateReferencesEffect,
+ expandRegionEffect,
+ updateReferences,
+ expandRegion,
+ FoldingState,
+} from './codeFoldingExtension'
+import { FileReference } from '../../types'
+import { EditorState, StateField } from '@codemirror/state'
+
+describe('calculateVisibleRanges', () => {
+ test('applies padding to a single range', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: {
+ startLine: 10,
+ endLine: 15
+ },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 7, // 10 - 3
+ endLine: 18 // 15 + 3
+ }]);
+ });
+
+ test('merges overlapping ranges', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 12, endLine: 20 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 7, // 10 - 3
+ endLine: 23 // 20 + 3
+ }]);
+ });
+
+ test('merges adjacent ranges (including padding)', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 19, endLine: 25 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ // Range 1: 7-18, Range 2: 16-28
+ // Since 18 >= 16-1 (15), they should merge
+ expect(visibleRanges).toEqual([{
+ startLine: 7,
+ endLine: 28
+ }]);
+ });
+
+ test('keeps separate ranges when they dont overlap', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 25, endLine: 30 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([
+ { startLine: 7, endLine: 18 }, // 10-15 with padding
+ { startLine: 22, endLine: 33 } // 25-30 with padding
+ ]);
+ });
+
+ test('respects file boundaries - start of file', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 1, endLine: 5 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 1, // Can't go below 1
+ endLine: 8 // 5 + 3
+ }]);
+ });
+
+ test('respects file boundaries - end of file', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 95, endLine: 100 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 92, // 95 - 3
+ endLine: 100 // Can't go above 100
+ }]);
+ });
+
+ test('handles multiple ranges with complex overlaps', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 20, endLine: 25 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '3',
+ type: 'file',
+ range: { startLine: 22, endLine: 30 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '4',
+ type: 'file',
+ range: { startLine: 50, endLine: 55 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([
+ { startLine: 7, endLine: 33 }, // All three ranges merge: 10-15, 20-25, 22-30 with padding
+ { startLine: 47, endLine: 58 } // Last range: 50-55 with padding
+ ]);
+ });
+
+ test('returns full file when no ranges provided', () => {
+ const references: FileReference[] = [];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 1,
+ endLine: 100
+ }]);
+ });
+
+ test('ignores references without ranges', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ // No range property
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 7,
+ endLine: 18
+ }]);
+ });
+
+ test('works with zero padding', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 0);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 10,
+ endLine: 15
+ }]);
+ });
+
+ test('handles single line ranges', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 10 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 2);
+
+ expect(visibleRanges).toEqual([{
+ startLine: 8,
+ endLine: 12
+ }]);
+ });
+
+ test('sorts ranges by start line', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 50, endLine: 55 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '3',
+ type: 'file',
+ range: { startLine: 30, endLine: 35 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const visibleRanges = calculateVisibleRanges(references, 100, 3);
+
+ expect(visibleRanges).toEqual([
+ { startLine: 7, endLine: 18 }, // 10-15 range (sorted first)
+ { startLine: 27, endLine: 38 }, // 30-35 range
+ { startLine: 47, endLine: 58 } // 50-55 range
+ ]);
+ });
+});
+
+describe('calculateHiddenRegions', () => {
+ test('calculates hidden regions between visible ranges', () => {
+ const visibleRanges = [
+ { startLine: 10, endLine: 20 },
+ { startLine: 30, endLine: 40 },
+ { startLine: 60, endLine: 70 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 100);
+
+ expect(hiddenRegions).toEqual([
+ { startLine: 1, endLine: 9, canExpandUp: true, canExpandDown: false }, // Before first range
+ { startLine: 21, endLine: 29, canExpandUp: true, canExpandDown: true }, // Between first and second
+ { startLine: 41, endLine: 59, canExpandUp: true, canExpandDown: true }, // Between second and third
+ { startLine: 71, endLine: 100, canExpandUp: false, canExpandDown: true } // After last range
+ ]);
+ });
+
+ test('handles visible range starting at line 1', () => {
+ const visibleRanges = [
+ { startLine: 1, endLine: 10 },
+ { startLine: 20, endLine: 30 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([
+ { startLine: 11, endLine: 19, canExpandUp: true, canExpandDown: true }, // Between ranges
+ { startLine: 31, endLine: 50, canExpandUp: false, canExpandDown: true } // After last range
+ ]);
+ });
+
+ test('handles visible range ending at last line', () => {
+ const visibleRanges = [
+ { startLine: 10, endLine: 20 },
+ { startLine: 30, endLine: 50 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([
+ { startLine: 1, endLine: 9, canExpandUp: true, canExpandDown: false }, // Before first range
+ { startLine: 21, endLine: 29, canExpandUp: true, canExpandDown: true } // Between ranges
+ ]);
+ });
+
+ test('handles single visible range in middle', () => {
+ const visibleRanges = [
+ { startLine: 20, endLine: 30 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([
+ { startLine: 1, endLine: 19, canExpandUp: true, canExpandDown: false }, // Before range
+ { startLine: 31, endLine: 50, canExpandUp: false, canExpandDown: true } // After range
+ ]);
+ });
+
+ test('handles single visible range covering entire file', () => {
+ const visibleRanges = [
+ { startLine: 1, endLine: 50 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([]);
+ });
+
+ test('handles adjacent visible ranges', () => {
+ const visibleRanges = [
+ { startLine: 10, endLine: 20 },
+ { startLine: 21, endLine: 30 }
+ ];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([
+ { startLine: 1, endLine: 9, canExpandUp: true, canExpandDown: false }, // Before first range
+ { startLine: 31, endLine: 50, canExpandUp: false, canExpandDown: true } // After last range
+ ]);
+ });
+
+ test('handles empty visible ranges', () => {
+ const visibleRanges: Array<{ startLine: number; endLine: number }> = [];
+
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, 50);
+
+ expect(hiddenRegions).toEqual([]);
+ });
+});
+
+describe('StateField Integration', () => {
+ test('initial state calculation with references', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 25, endLine: 30 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const extension = createCodeFoldingExtension(references, 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ // Create document with 50 lines
+ const doc = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n');
+ const state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ const foldingState = state.field(stateField);
+
+ expect(foldingState.totalLines).toBe(50);
+ expect(foldingState.references).toEqual(references);
+ expect(foldingState.padding).toBe(3);
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 7, endLine: 18 }, // 10-15 with padding
+ { startLine: 22, endLine: 33 } // 25-30 with padding
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 6, canExpandUp: true, canExpandDown: false }, // Before first range
+ { startLine: 19, endLine: 21, canExpandUp: true, canExpandDown: true }, // Between ranges
+ { startLine: 34, endLine: 50, canExpandUp: false, canExpandDown: true } // After last range
+ ]);
+ });
+
+ test('initial state with no references shows entire file', () => {
+ const extension = createCodeFoldingExtension([], 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join('\n');
+ const state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ const foldingState = state.field(stateField);
+
+ expect(foldingState.totalLines).toBe(20);
+ expect(foldingState.references).toEqual([]);
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 1, endLine: 20 }
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([]);
+ });
+
+ test('updateReferencesEffect changes state correctly', () => {
+ const extension = createCodeFoldingExtension([], 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 30 }, (_, i) => `line${i + 1}`).join('\n');
+ let state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ // Initially no references, should show entire file
+ let foldingState = state.field(stateField);
+ expect(foldingState.references).toEqual([]);
+ expect(foldingState.visibleRanges).toEqual([{ startLine: 1, endLine: 30 }]);
+
+ // Update references
+ const newReferences: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ state = state.update({
+ effects: [updateReferencesEffect.of(newReferences)]
+ }).state;
+
+ foldingState = state.field(stateField);
+ expect(foldingState.references).toEqual(newReferences);
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 7, endLine: 18 } // 10-15 with padding 3
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 6, canExpandUp: true, canExpandDown: false },
+ { startLine: 19, endLine: 30, canExpandUp: false, canExpandDown: true }
+ ]);
+ });
+
+ test('expandRegionEffect expands hidden region up', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 20, endLine: 25 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const extension = createCodeFoldingExtension(references, 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n');
+ let state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ // Initial state should have hidden regions before and after the visible range
+ let foldingState = state.field(stateField);
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 17, endLine: 28 } // 20-25 with padding 3
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 16, canExpandUp: true, canExpandDown: false }, // Before
+ { startLine: 29, endLine: 50, canExpandUp: false, canExpandDown: true } // After
+ ]);
+
+ // Expand the first hidden region (before the visible range) upward by 10 lines
+ state = state.update({
+ effects: [expandRegionEffect.of({ regionIndex: 0, direction: 'up', linesToExpand: 10 })]
+ }).state;
+
+ foldingState = state.field(stateField);
+
+ // Should now have two visible ranges: the expanded region and the original range
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 7, endLine: 28 } // Merged range (7-28)
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 6, canExpandUp: true, canExpandDown: false }, // Between expanded and original
+ { startLine: 29, endLine: 50, canExpandUp: false, canExpandDown: true } // After original range
+ ]);
+ });
+
+ test('expandRegionEffect expands hidden region down', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 20, endLine: 25 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const extension = createCodeFoldingExtension(references, 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n');
+ let state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ // Expand the last hidden region (after the visible range) downward by 10 lines
+ state = state.update({
+ effects: [expandRegionEffect.of({ regionIndex: 1, direction: 'down', linesToExpand: 10 })]
+ }).state;
+
+ const foldingState = state.field(stateField);
+
+ // Should now have merged ranges: the original range and the expanded region
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 17, endLine: 38 } // Merged range: original (17-28) + expanded (29-38)
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 16, canExpandUp: true, canExpandDown: false }, // Before merged range
+ { startLine: 39, endLine: 50, canExpandUp: false, canExpandDown: true } // After merged range
+ ]);
+ });
+
+ test('document changes recalculate state', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ const extension = createCodeFoldingExtension(references, 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join('\n');
+ let state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ let foldingState = state.field(stateField);
+ expect(foldingState.totalLines).toBe(20);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 6, canExpandUp: true, canExpandDown: false },
+ { startLine: 19, endLine: 20, canExpandUp: false, canExpandDown: true }
+ ]);
+
+ // Insert 10 new lines at the beginning
+ const newLines = Array.from({ length: 10 }, (_, i) => `newline${i + 1}`).join('\n');
+ state = state.update({
+ changes: { from: 0, insert: newLines + '\n' }
+ }).state;
+
+ foldingState = state.field(stateField);
+ expect(foldingState.totalLines).toBe(30); // 20 + 10 (inserting 10 lines with newlines)
+
+ // Hidden regions should be recalculated with new total lines
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 6, canExpandUp: true, canExpandDown: false },
+ { startLine: 19, endLine: 30, canExpandUp: false, canExpandDown: true }
+ ]);
+ });
+
+ test('action creators work correctly', () => {
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 10, endLine: 15 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ // Test updateReferences action creator
+ const updateAction = updateReferences(references);
+ expect(updateAction.effects).toHaveLength(1);
+ expect(updateAction.effects[0].is(updateReferencesEffect)).toBe(true);
+ expect(updateAction.effects[0].value).toEqual(references);
+
+ // Test expandRegion action creator
+ const expandAction = expandRegion(0, 'down', 15);
+ expect(expandAction.effects).toHaveLength(1);
+ expect(expandAction.effects[0].is(expandRegionEffect)).toBe(true);
+ expect(expandAction.effects[0].value).toEqual({
+ regionIndex: 0,
+ direction: 'down',
+ linesToExpand: 15
+ });
+ });
+
+ test('complex state transitions with multiple effects', () => {
+ const extension = createCodeFoldingExtension([], 3);
+ const stateField = (extension as any)[0] as StateField;
+
+ const doc = Array.from({ length: 100 }, (_, i) => `line${i + 1}`).join('\n');
+ let state = EditorState.create({
+ doc,
+ extensions: [extension],
+ });
+
+ // Start with no references - should show entire file
+ let foldingState = state.field(stateField);
+ expect(foldingState.visibleRanges).toEqual([{ startLine: 1, endLine: 100 }]);
+
+ // Add references
+ const references: FileReference[] = [
+ {
+ path: 'test.ts',
+ id: '1',
+ type: 'file',
+ range: { startLine: 20, endLine: 25 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ },
+ {
+ path: 'test.ts',
+ id: '2',
+ type: 'file',
+ range: { startLine: 60, endLine: 65 },
+ repo: 'github.com/sourcebot-dev/sourcebot'
+ }
+ ];
+
+ state = state.update({
+ effects: [updateReferencesEffect.of(references)]
+ }).state;
+
+ foldingState = state.field(stateField);
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 17, endLine: 28 }, // 20-25 with padding
+ { startLine: 57, endLine: 68 } // 60-65 with padding
+ ]);
+ expect(foldingState.hiddenRegions).toHaveLength(3); // before, between, after
+
+ // Expand the middle hidden region
+ state = state.update({
+ effects: [expandRegionEffect.of({ regionIndex: 1, direction: 'down', linesToExpand: 20 })]
+ }).state;
+
+ foldingState = state.field(stateField);
+ // Should merge the first range with the expanded region
+ expect(foldingState.visibleRanges).toEqual([
+ { startLine: 17, endLine: 48 }, // Merged first range + expanded region
+ { startLine: 57, endLine: 68 } // Second range unchanged
+ ]);
+ expect(foldingState.hiddenRegions).toEqual([
+ { startLine: 1, endLine: 16, canExpandUp: true, canExpandDown: false }, // Before all ranges
+ { startLine: 49, endLine: 56, canExpandUp: true, canExpandDown: true }, // Between merged and second range
+ { startLine: 69, endLine: 100, canExpandUp: false, canExpandDown: true } // After all ranges
+ ]);
+ });
+});
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.ts b/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.ts
new file mode 100644
index 00000000..6dc96d90
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.ts
@@ -0,0 +1,505 @@
+import { Extension, StateField, StateEffect, Transaction, Range as CodeMirrorRange, EditorState } from "@codemirror/state";
+import {
+ Decoration,
+ DecorationSet,
+ EditorView,
+ WidgetType
+} from "@codemirror/view";
+import { FileReference } from "../../types";
+import React, { CSSProperties } from "react";
+import { createRoot } from "react-dom/client";
+import { CodeFoldingExpandButton } from "./codeFoldingExpandButton";
+import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
+
+interface Range {
+ startLine: number;
+ endLine: number;
+}
+
+interface HiddenRegion {
+ startLine: number;
+ endLine: number;
+ canExpandUp: boolean;
+ canExpandDown: boolean;
+}
+
+export interface FoldingState {
+ visibleRanges: Range[];
+ hiddenRegions: HiddenRegion[];
+ totalLines: number;
+ references: FileReference[];
+ padding: number;
+}
+
+// State effects for updating folding state
+export const updateReferencesEffect = StateEffect.define();
+export const expandRegionEffect = StateEffect.define<{
+ regionIndex: number;
+ direction: 'up' | 'down';
+ linesToExpand: number;
+}>();
+
+// Range calculation utilities
+export const calculateVisibleRanges = (
+ references: FileReference[],
+ totalLines: number,
+ padding: number = 3
+): Range[] => {
+ // Extract ranges from references that have them
+ const ranges: Range[] = references
+ .filter(ref => ref.range !== undefined)
+ .map(ref => ({
+ startLine: Math.max(1, ref.range!.startLine - padding),
+ endLine: Math.min(totalLines, ref.range!.endLine + padding),
+ }));
+
+ // If no ranges, show everything
+ if (ranges.length === 0) {
+ return [{ startLine: 1, endLine: totalLines }];
+ }
+
+ // Sort ranges by start line
+ ranges.sort((a, b) => a.startLine - b.startLine);
+
+ // Merge overlapping ranges
+ const mergedRanges: Range[] = [];
+ let currentRange = ranges[0];
+
+ for (let i = 1; i < ranges.length; i++) {
+ const nextRange = ranges[i];
+
+ // Check if ranges overlap or are adjacent
+ if (currentRange.endLine >= nextRange.startLine - 1) {
+ // Merge ranges
+ currentRange.endLine = Math.max(currentRange.endLine, nextRange.endLine);
+ } else {
+ // No overlap, add current range and start new one
+ mergedRanges.push(currentRange);
+ currentRange = nextRange;
+ }
+ }
+
+ // Add the last range
+ mergedRanges.push(currentRange);
+
+ return mergedRanges;
+};
+
+export const calculateHiddenRegions = (
+ visibleRanges: Range[],
+ totalLines: number
+): HiddenRegion[] => {
+ const hiddenRegions: HiddenRegion[] = [];
+
+ // Hidden region before first visible range
+ if (visibleRanges.length > 0 && visibleRanges[0].startLine > 1) {
+ hiddenRegions.push({
+ startLine: 1,
+ endLine: visibleRanges[0].startLine - 1,
+ canExpandUp: true, // Can expand toward start of file
+ canExpandDown: false, // Can't expand toward visible content
+ });
+ }
+
+ // Hidden regions between visible ranges
+ for (let i = 0; i < visibleRanges.length - 1; i++) {
+ const currentRange = visibleRanges[i];
+ const nextRange = visibleRanges[i + 1];
+
+ if (currentRange.endLine + 1 < nextRange.startLine) {
+ hiddenRegions.push({
+ startLine: currentRange.endLine + 1,
+ endLine: nextRange.startLine - 1,
+ canExpandUp: true,
+ canExpandDown: true,
+ });
+ }
+ }
+
+ // Hidden region after last visible range
+ if (visibleRanges.length > 0) {
+ const lastRange = visibleRanges[visibleRanges.length - 1];
+ if (lastRange.endLine < totalLines) {
+ hiddenRegions.push({
+ startLine: lastRange.endLine + 1,
+ endLine: totalLines,
+ canExpandUp: false, // Can't expand toward visible content
+ canExpandDown: true, // Can expand toward end of file
+ });
+ }
+ }
+
+ return hiddenRegions;
+};
+
+export const createFoldingState = (
+ references: FileReference[],
+ totalLines: number,
+ padding: number = 3
+): FoldingState => {
+ const visibleRanges = calculateVisibleRanges(references, totalLines, padding);
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, totalLines);
+
+ return {
+ visibleRanges,
+ hiddenRegions,
+ totalLines,
+ references,
+ padding,
+ };
+};
+
+// State field management is now handled inside createCodeFoldingExtension
+
+// Helper function to recalculate folding state
+const recalculateFoldingState = (state: FoldingState): FoldingState => {
+ const visibleRanges = calculateVisibleRanges(state.references, state.totalLines, state.padding);
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, state.totalLines);
+
+ return {
+ ...state,
+ visibleRanges,
+ hiddenRegions,
+ };
+};
+
+// Helper function to expand a region
+const expandRegionInternal = (
+ currentState: FoldingState,
+ hiddenRegionIndex: number,
+ direction: 'up' | 'down',
+ linesToExpand: number = 20
+): FoldingState => {
+ const hiddenRegion = currentState.hiddenRegions[hiddenRegionIndex];
+ if (!hiddenRegion) return currentState;
+
+ const newVisibleRanges = [...currentState.visibleRanges];
+
+ if (direction === 'up' && hiddenRegion.canExpandUp) {
+ const startLine = Math.max(hiddenRegion.startLine, hiddenRegion.endLine - linesToExpand + 1);
+ newVisibleRanges.push({
+ startLine,
+ endLine: hiddenRegion.endLine,
+ });
+ } else if (direction === 'down' && hiddenRegion.canExpandDown) {
+ const endLine = Math.min(hiddenRegion.endLine, hiddenRegion.startLine + linesToExpand - 1);
+ newVisibleRanges.push({
+ startLine: hiddenRegion.startLine,
+ endLine,
+ });
+ }
+
+ // Sort and merge overlapping ranges
+ const sortedRanges = newVisibleRanges.sort((a, b) => a.startLine - b.startLine);
+ const mergedRanges = mergeOverlappingRanges(sortedRanges);
+ const newHiddenRegions = calculateHiddenRegions(mergedRanges, currentState.totalLines);
+
+ return {
+ ...currentState,
+ visibleRanges: mergedRanges,
+ hiddenRegions: newHiddenRegions,
+ };
+};
+
+const mergeOverlappingRanges = (ranges: Range[]): Range[] => {
+ if (ranges.length === 0) return [];
+
+ // Sort ranges by start line
+ const sortedRanges = [...ranges].sort((a, b) => a.startLine - b.startLine);
+
+ const merged: Range[] = [];
+ let currentRange = sortedRanges[0];
+
+ for (let i = 1; i < sortedRanges.length; i++) {
+ const nextRange = sortedRanges[i];
+
+ // Check if ranges overlap or are adjacent
+ if (currentRange.endLine >= nextRange.startLine - 1) {
+ currentRange.endLine = Math.max(currentRange.endLine, nextRange.endLine);
+ } else {
+ merged.push(currentRange);
+ currentRange = nextRange;
+ }
+ }
+
+ merged.push(currentRange);
+ return merged;
+};
+
+// Action creators for dispatching state updates
+export const updateReferences = (references: FileReference[]) => {
+ return {
+ effects: [updateReferencesEffect.of(references)],
+ };
+};
+
+export const expandRegion = (regionIndex: number, direction: 'up' | 'down', linesToExpand: number = 20) => {
+ return {
+ effects: [expandRegionEffect.of({ regionIndex, direction, linesToExpand })],
+ };
+};
+
+
+// Widget for expand buttons
+class CodeFoldingExpandButtonWidget extends WidgetType {
+ constructor(
+ private regionIndex: number,
+ private direction: 'up' | 'down',
+ private canExpandUp: boolean,
+ private canExpandDown: boolean,
+ private hiddenLineCount: number
+ ) {
+ super();
+ }
+
+ toDOM(view: EditorView): HTMLElement {
+ const container = document.createElement('div');
+ container.className = 'cm-code-folding-expand-container';
+
+ // Create React root and render component
+ const root = createRoot(container);
+ root.render(
+ React.createElement(CodeFoldingExpandButton, {
+ hiddenLineCount: this.hiddenLineCount,
+ canExpandUp: this.canExpandUp,
+ canExpandDown: this.canExpandDown,
+ onExpand: (direction) => {
+ view.dispatch({
+ effects: [expandRegionEffect.of({
+ regionIndex: this.regionIndex,
+ direction,
+ linesToExpand: 20
+ })]
+ });
+ },
+ })
+ );
+
+ // Store references for potential updates
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (container as any)._codeFoldingRoot = root;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (container as any)._updateStyling = (newGutterWidth: number) => {
+ this.updateContainerStyling(container, newGutterWidth);
+ };
+
+ return container;
+ }
+
+ private updateContainerStyling(container: HTMLElement, gutterWidth: number) {
+ container.style.marginLeft = `-${gutterWidth}px`;
+ container.style.width = `calc(100% + ${gutterWidth}px)`;
+ }
+
+ eq(other: CodeFoldingExpandButtonWidget): boolean {
+ return (
+ this.regionIndex === other.regionIndex &&
+ this.direction === other.direction &&
+ this.canExpandUp === other.canExpandUp &&
+ this.canExpandDown === other.canExpandDown &&
+ this.hiddenLineCount === other.hiddenLineCount
+ );
+ }
+}
+
+// Function to create decorations from folding state
+const createDecorations = (state: EditorState, foldingState: FoldingState): DecorationSet => {
+ const decorations: CodeMirrorRange[] = [];
+
+ // Create decorations for each hidden region
+ foldingState.hiddenRegions.forEach((region, index) => {
+
+ // Catch cases where the region is outside the document bounds.
+ if (
+ region.startLine < 1 ||
+ region.startLine > state.doc.lines ||
+ region.endLine < 1 ||
+ region.endLine > state.doc.lines
+ ) {
+ return;
+ }
+
+ const from = state.doc.line(region.startLine).from;
+ const to = state.doc.line(region.endLine).to;
+ const hiddenLineCount = region.endLine - region.startLine + 1;
+
+ // Create a widget that replaces the hidden region
+ const widget = new CodeFoldingExpandButtonWidget(
+ index,
+ 'down', // Default direction
+ region.canExpandUp,
+ region.canExpandDown,
+ hiddenLineCount
+ );
+
+ // Replace the entire hidden region with the expand button
+ const decoration = Decoration.replace({
+ widget,
+ block: true,
+ inclusive: true,
+ });
+
+ decorations.push(decoration.range(from, to));
+ });
+
+ // Sort decorations by their 'from' position to ensure proper ordering
+ decorations.sort((a, b) => a.from - b.from);
+
+ return Decoration.set(decorations);
+};
+
+// Combined StateField that manages both folding state and decorations
+interface FoldingStateWithDecorations extends FoldingState {
+ decorations: DecorationSet;
+}
+
+const createFoldingStateWithDecorations = (
+ references: FileReference[],
+ totalLines: number,
+ padding: number = 3
+): FoldingStateWithDecorations => {
+ const visibleRanges = calculateVisibleRanges(references, totalLines, padding);
+ const hiddenRegions = calculateHiddenRegions(visibleRanges, totalLines);
+
+ const foldingState: FoldingState = {
+ visibleRanges,
+ hiddenRegions,
+ totalLines,
+ references,
+ padding,
+ };
+
+ return {
+ ...foldingState,
+ decorations: Decoration.set([]), // Will be updated in the create function
+ };
+};
+
+export const createCodeFoldingExtension = (
+ references: FileReference[] = [],
+ padding: number = 3
+): Extension => {
+ const foldingStateField = StateField.define({
+ create(state): FoldingStateWithDecorations {
+ const totalLines = state.doc.lines;
+ const stateWithDecorations = createFoldingStateWithDecorations(references, totalLines, padding);
+
+ // Create decorations for the initial state
+ const decorations = createDecorations(state, stateWithDecorations);
+
+ return {
+ ...stateWithDecorations,
+ decorations,
+ };
+ },
+
+ update(currentState: FoldingStateWithDecorations, transaction: Transaction): FoldingStateWithDecorations {
+ let newState = currentState;
+
+ // Update total lines if document changed
+ if (transaction.docChanged) {
+ const newTotalLines = transaction.newDoc.lines;
+ if (newTotalLines !== currentState.totalLines) {
+ newState = {
+ ...currentState,
+ totalLines: newTotalLines,
+ };
+ // Recalculate ranges with new total lines
+ const recalculatedState = recalculateFoldingState(newState);
+ newState = {
+ ...recalculatedState,
+ decorations: newState.decorations,
+ };
+ }
+ }
+
+ // Handle state effects
+ for (const effect of transaction.effects) {
+ if (effect.is(updateReferencesEffect)) {
+ newState = {
+ ...newState,
+ references: effect.value,
+ };
+ const recalculatedState = recalculateFoldingState(newState);
+ newState = {
+ ...recalculatedState,
+ decorations: newState.decorations,
+ };
+ } else if (effect.is(expandRegionEffect)) {
+ const expandedState = expandRegionInternal(newState, effect.value.regionIndex, effect.value.direction, effect.value.linesToExpand);
+ newState = {
+ ...expandedState,
+ decorations: newState.decorations,
+ };
+ }
+ }
+
+ // Update decorations if state changed or document changed
+ if (newState !== currentState || transaction.docChanged) {
+ const decorations = createDecorations(transaction.state, newState);
+ newState = {
+ ...newState,
+ decorations,
+ };
+ } else {
+ // Map existing decorations to new document
+ newState = {
+ ...newState,
+ decorations: currentState.decorations.map(transaction.changes),
+ };
+ }
+
+ return newState;
+ },
+
+ provide: field => EditorView.decorations.from(field, state => state.decorations),
+ });
+
+ // View plugin to handle gutter width updates
+ const gutterUpdatePlugin = EditorView.updateListener.of((update) => {
+ if (update.geometryChanged) {
+ const gutterPlugin = update.view.plugin(gutterWidthExtension);
+ if (gutterPlugin) {
+ const newGutterWidth = gutterPlugin.width;
+
+ // Update all expand button containers
+ const expandContainers = update.view.dom.querySelectorAll('.cm-code-folding-expand-container');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expandContainers.forEach((container: any) => {
+ if (container._updateStyling) {
+ container._updateStyling(newGutterWidth);
+ }
+ });
+ }
+ }
+ });
+
+ const codeFoldingTheme = EditorView.theme({
+ '.cm-code-folding-expand-container': {
+ marginLeft: '0px',
+ width: '100%',
+ zIndex: 300,
+ cursor: 'pointer',
+ } satisfies CSSProperties,
+
+ // Remove top padding from cm-content
+ '.cm-content': {
+ paddingTop: '0px',
+ paddingBottom: '0px',
+ } satisfies CSSProperties,
+
+ // This is required, otherwise the expand button will not be clickable
+ // when it is rendered over the gutter
+ '.cm-gutters': {
+ pointerEvents: 'none',
+ } satisfies CSSProperties,
+ });
+
+ return [
+ foldingStateField,
+ gutterWidthExtension,
+ gutterUpdatePlugin,
+ codeFoldingTheme,
+ ];
+};
+
diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx
new file mode 100644
index 00000000..0fe18a64
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import { Card, CardContent } from '@/components/ui/card';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Separator } from '@/components/ui/separator';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, List, ScanSearchIcon, Zap } from 'lucide-react';
+import { MarkdownRenderer } from './markdownRenderer';
+import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent';
+import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent';
+import { ReadFilesToolComponent } from './tools/readFilesToolComponent';
+import { SearchCodeToolComponent } from './tools/searchCodeToolComponent';
+import { SearchReposToolComponent } from './tools/searchReposToolComponent';
+import { ListAllReposToolComponent } from './tools/listAllReposToolComponent';
+import { SBChatMessageMetadata, SBChatMessagePart } from '../../types';
+import { SearchScopeIcon } from '../searchScopeIcon';
+
+
+interface DetailsCardProps {
+ isExpanded: boolean;
+ onExpandedChanged: (isExpanded: boolean) => void;
+ isThinking: boolean;
+ isStreaming: boolean;
+ thinkingSteps: SBChatMessagePart[][];
+ metadata?: SBChatMessageMetadata;
+}
+
+export const DetailsCard = ({
+ isExpanded,
+ onExpandedChanged,
+ isThinking,
+ isStreaming,
+ metadata,
+ thinkingSteps,
+}: DetailsCardProps) => {
+
+ return (
+
+
+
+
+
+
+
+
+ {isThinking ? (
+ <>
+
+ Thinking...
+ >
+ ) : (
+ <>
+
+ Details
+ >
+ )}
+
+ {!isStreaming && (
+ <>
+
+ {(metadata?.selectedSearchScopes && metadata.selectedSearchScopes.length > 0) && (
+
+
+
+
+ {metadata.selectedSearchScopes.length} search scope{metadata.selectedSearchScopes.length === 1 ? '' : 's'}
+
+
+
+
+
+ {metadata.selectedSearchScopes.map((item) => (
+
+
+ {item.name}
+
+ ))}
+
+
+
+
+ )}
+ {metadata?.modelName && (
+
+
+ {metadata?.modelName}
+
+ )}
+ {metadata?.totalTokens && (
+
+
+ {metadata?.totalTokens} tokens
+
+ )}
+ {metadata?.totalResponseTimeMs && (
+
+
+ {metadata?.totalResponseTimeMs / 1000} seconds
+
+ )}
+
+
+ {`${thinkingSteps.length} step${thinkingSteps.length === 1 ? '' : 's'}`}
+
+ >
+ )}
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {thinkingSteps.length === 0 ? (
+ isStreaming ? (
+
+ ) : (
+ No thinking steps
+ )
+ ) : thinkingSteps.map((step, index) => {
+ return (
+
+
+
+ {index + 1}
+
+
+ {step.map((part, index) => {
+ switch (part.type) {
+ case 'reasoning':
+ case 'text':
+ return (
+
+ )
+ case 'tool-readFiles':
+ return (
+
+ )
+ case 'tool-searchCode':
+ return (
+
+ )
+ case 'tool-findSymbolDefinitions':
+ return (
+
+ )
+ case 'tool-findSymbolReferences':
+ return (
+
+ )
+ case 'tool-searchRepos':
+ return (
+
+ )
+ case 'tool-listAllRepos':
+ return (
+
+ )
+ default:
+ return null;
+ }
+ })}
+
+ )
+ })}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/errorBanner.tsx b/packages/web/src/features/chat/components/chatThread/errorBanner.tsx
new file mode 100644
index 00000000..2020e29f
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/errorBanner.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { serviceErrorSchema } from '@/lib/serviceError';
+import { AlertCircle, X } from "lucide-react";
+import { useMemo } from 'react';
+
+interface ErrorBannerProps {
+ error: Error;
+ isVisible: boolean;
+ onClose: () => void;
+}
+
+export const ErrorBanner = ({ error, isVisible, onClose }: ErrorBannerProps) => {
+ const errorMessage = useMemo(() => {
+ try {
+ const errorJson = JSON.parse(error.message);
+ const serviceError = serviceErrorSchema.parse(errorJson);
+ return serviceError.message;
+ } catch {
+ return error.message;
+ }
+ }, [error]);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ Error occurred
+
+
+ {errorMessage}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/index.ts b/packages/web/src/features/chat/components/chatThread/index.ts
new file mode 100644
index 00000000..24a7a7ff
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/index.ts
@@ -0,0 +1 @@
+export { ChatThread } from './chatThread';
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/markdownRenderer.tsx b/packages/web/src/features/chat/components/chatThread/markdownRenderer.tsx
new file mode 100644
index 00000000..a4992aa0
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/markdownRenderer.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { CodeSnippet } from '@/app/components/codeSnippet';
+import { useDomain } from '@/hooks/useDomain';
+import { SearchQueryParams } from '@/lib/types';
+import { cn, createPathWithQueryParams } from '@/lib/utils';
+import type { Element, Root } from "hast";
+import { Schema as SanitizeSchema } from 'hast-util-sanitize';
+import { CopyIcon, SearchIcon } from 'lucide-react';
+import type { Heading, Nodes } from "mdast";
+import { findAndReplace } from 'mdast-util-find-and-replace';
+import { useRouter } from 'next/navigation';
+import React, { useCallback, useMemo, forwardRef } from 'react';
+import Markdown from 'react-markdown';
+import rehypeRaw from 'rehype-raw';
+import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
+import remarkGfm from 'remark-gfm';
+import type { PluggableList, Plugin } from "unified";
+import { visit } from 'unist-util-visit';
+import { CodeBlock } from './codeBlock';
+import { FILE_REFERENCE_REGEX } from '@/features/chat/constants';
+import { createFileReference } from '@/features/chat/utils';
+
+export const REFERENCE_PAYLOAD_ATTRIBUTE = 'data-reference-payload';
+
+const annotateCodeBlocks: Plugin<[], Root> = () => {
+ return (tree: Root) => {
+ visit(tree, 'element', (node, _index, parent) => {
+ if (node.tagName !== 'code' || !parent || !('tagName' in parent)) {
+ return;
+ }
+
+ if (parent.tagName === 'pre') {
+ node.properties.isBlock = true;
+ parent.properties.isBlock = true;
+ } else {
+ node.properties.isBlock = false;
+ }
+ })
+ }
+}
+
+// @see: https://unifiedjs.com/learn/guide/create-a-remark-plugin/
+function remarkReferencesPlugin() {
+ return function (tree: Nodes) {
+ findAndReplace(tree, [
+ FILE_REFERENCE_REGEX,
+ (_, repo: string, fileName: string, startLine?: string, endLine?: string) => {
+ // Create display text
+ let displayText = fileName.split('/').pop() ?? fileName;
+
+ const fileReference = createFileReference({
+ repo: repo,
+ path: fileName,
+ startLine,
+ endLine,
+ });
+
+ if (fileReference.range) {
+ displayText += `:${fileReference.range.startLine}-${fileReference.range.endLine}`;
+ }
+
+ return {
+ type: 'html',
+ // @note: if you add additional attributes to this span, make sure to update the rehypeSanitize plugin to allow them.
+ //
+ // @note: we attach the reference id to the DOM element as a class name since there may be multiple reference elements
+ // with the same id (i.e., referencing the same file & range).
+ value: `${displayText} `
+ }
+ }
+ ])
+ }
+}
+
+const remarkTocExtractor = () => {
+ return function (tree: Nodes) {
+ visit(tree, 'heading', (node: Heading) => {
+ const textContent = node.children
+ .filter((child) => child.type === 'text')
+ .map((child) => child.value)
+ .join('');
+
+ const id = textContent.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, '-');
+
+ // Add id to the heading node for linking
+ node.data = node.data || {};
+ node.data.hProperties = node.data.hProperties || {};
+ node.data.hProperties.id = id;
+ });
+ };
+}
+
+interface MarkdownRendererProps {
+ content: string;
+ className?: string;
+}
+
+export const MarkdownRenderer = forwardRef(({ content, className }, ref) => {
+ const domain = useDomain();
+ const router = useRouter();
+
+ const remarkPlugins = useMemo((): PluggableList => {
+ return [
+ remarkGfm,
+ remarkReferencesPlugin,
+ remarkTocExtractor,
+ ];
+ }, []);
+
+ const rehypePlugins = useMemo((): PluggableList => {
+ return [
+ rehypeRaw,
+ [
+ rehypeSanitize,
+ {
+ ...defaultSchema,
+ attributes: {
+ ...defaultSchema.attributes,
+ span: [...(defaultSchema.attributes?.span ?? []), 'role', 'className', 'data*'],
+ },
+ strip: [],
+ } satisfies SanitizeSchema,
+ ],
+ annotateCodeBlocks,
+ ];
+ }, []);
+
+ const renderPre = useCallback(({ children, node, ...rest }: React.JSX.IntrinsicElements['pre'] & { node?: Element }) => {
+ if (node?.properties && node.properties.isBlock === true) {
+ return children;
+ }
+
+ return (
+
+ {children}
+
+ )
+ }, []);
+
+ const renderCode = useCallback(({ className, children, node, ...rest }: React.JSX.IntrinsicElements['code'] & { node?: Element }) => {
+ const text = children?.toString().trimEnd() ?? '';
+
+ if (node?.properties && node.properties.isBlock === true) {
+ const match = /language-(\w+)/.exec(className || '');
+ const language = match ? match[1] : undefined;
+
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {children}
+
+
+ {/* Invisible bridge to prevent hover gap */}
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, `"${text}"`])
+ router.push(url);
+ }}
+ title="Search for snippet"
+ >
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ navigator.clipboard.writeText(text);
+ }}
+ title="Copy snippet"
+ >
+
+
+
+
+
+ )
+
+ }, [domain, router]);
+
+ return (
+ *:first-child]:mt-0", className)}
+ >
+
+ {content}
+
+
+ );
+});
+
+MarkdownRenderer.displayName = 'MarkdownRenderer';
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx b/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx
new file mode 100644
index 00000000..904a8c05
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+import { useThemeNormalized } from '@/hooks/useThemeNormalized';
+import { useSession } from 'next-auth/react';
+import { SBChatMessage } from '../../types';
+
+interface MessageAvatarProps {
+ role: SBChatMessage['role'];
+ className?: string;
+}
+
+export const MessageAvatar = ({ role, className }: MessageAvatarProps) => {
+ // @todo: this should be based on the user who initiated the conversation.
+ const { data: session } = useSession();
+ const { theme } = useThemeNormalized();
+
+ return (
+
+ {role === "user" ? "U" : "AI"}
+ {role === "user" ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
diff --git a/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx b/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx
new file mode 100644
index 00000000..355e86e5
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx
@@ -0,0 +1,359 @@
+'use client';
+
+import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
+import { PathHeader } from "@/app/[domain]/components/pathHeader";
+import { SymbolHoverPopup } from '@/ee/features/codeNav/components/symbolHoverPopup';
+import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
+import { SymbolDefinition } from '@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo';
+import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
+import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
+import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
+import { useKeymapExtension } from "@/hooks/useKeymapExtension";
+import { cn } from "@/lib/utils";
+import { Range } from "@codemirror/state";
+import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
+import CodeMirror, { ReactCodeMirrorRef, StateField } from '@uiw/react-codemirror';
+import { ChevronDown, ChevronRight } from "lucide-react";
+import { forwardRef, Ref, useCallback, useImperativeHandle, useMemo, useState } from "react";
+import { FileReference } from "../../types";
+import { createCodeFoldingExtension } from "./codeFoldingExtension";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { createAuditAction } from "@/ee/features/audit/actions";
+import { useDomain } from "@/hooks/useDomain";
+
+const lineDecoration = Decoration.line({
+ attributes: { class: "cm-range-border-radius chat-lineHighlight" },
+});
+
+const selectedLineDecoration = Decoration.line({
+ attributes: { class: "cm-range-border-radius cm-range-border-shadow chat-lineHighlight-selected" },
+});
+
+const hoverLineDecoration = Decoration.line({
+ attributes: { class: "chat-lineHighlight-hover" },
+});
+
+
+interface ReferencedFileSourceListItemProps {
+ id: string;
+ code: string;
+ language: string;
+ revision: string;
+ repoName: string;
+ repoCodeHostType: string;
+ repoDisplayName?: string;
+ repoWebUrl?: string;
+ fileName: string;
+ references: FileReference[];
+ selectedReference?: FileReference;
+ hoveredReference?: FileReference;
+ onSelectedReferenceChanged: (reference?: FileReference) => void;
+ onHoveredReferenceChanged: (reference?: FileReference) => void;
+ isExpanded: boolean;
+ onExpandedChanged: (isExpanded: boolean) => void;
+}
+
+const ReferencedFileSourceListItem = ({
+ id,
+ code,
+ language,
+ revision,
+ repoName,
+ repoCodeHostType,
+ repoDisplayName,
+ repoWebUrl,
+ fileName,
+ references,
+ selectedReference,
+ hoveredReference,
+ onSelectedReferenceChanged,
+ onHoveredReferenceChanged,
+ isExpanded,
+ onExpandedChanged,
+}: ReferencedFileSourceListItemProps, forwardedRef: Ref) => {
+ const theme = useCodeMirrorTheme();
+ const [editorRef, setEditorRef] = useState(null);
+ const captureEvent = useCaptureEvent();
+ const domain = useDomain();
+
+ useImperativeHandle(
+ forwardedRef,
+ () => editorRef as ReactCodeMirrorRef
+ );
+ const keymapExtension = useKeymapExtension(editorRef?.view);
+ const hasCodeNavEntitlement = useHasEntitlement("code-nav");
+
+ const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
+ const { navigateToPath } = useBrowseNavigation();
+
+ const getReferenceAtPos = useCallback((x: number, y: number, view: EditorView): FileReference | undefined => {
+ const pos = view.posAtCoords({ x, y });
+ if (pos === null) return undefined;
+
+ // Check if position is within the main editor content area
+ const rect = view.contentDOM.getBoundingClientRect();
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
+ return undefined;
+ }
+
+ const line = view.state.doc.lineAt(pos);
+ const lineNumber = line.number;
+
+ // Check if this line is part of any highlighted range
+ const matchingRanges = references.filter(({ range }) =>
+ range && lineNumber >= range.startLine && lineNumber <= range.endLine
+ );
+
+ // Sort by the length of the range.
+ // Shorter ranges are more specific, so we want to prioritize them.
+ matchingRanges.sort((a, b) => {
+ const aLength = (a.range!.endLine) - (a.range!.startLine);
+ const bLength = (b.range!.endLine) - (b.range!.startLine);
+ return aLength - bLength;
+ });
+
+ if (matchingRanges.length > 0) {
+ return matchingRanges[0];
+ }
+
+ return undefined;
+ }, [references]);
+
+ const codeFoldingExtension = useMemo(() => {
+ return createCodeFoldingExtension(references, 3);
+ }, [references]);
+
+ const extensions = useMemo(() => {
+ return [
+ languageExtension,
+ EditorView.lineWrapping,
+ keymapExtension,
+ ...(hasCodeNavEntitlement ? [
+ symbolHoverTargetsExtension,
+ ] : []),
+ codeFoldingExtension,
+ StateField.define({
+ create(state) {
+ const decorations: Range[] = [];
+
+ for (const { range, id } of references) {
+ if (!range) {
+ continue;
+ }
+
+ const isHovered = id === hoveredReference?.id;
+ const isSelected = id === selectedReference?.id;
+
+ for (let line = range.startLine; line <= range.endLine; line++) {
+ // Skip lines that are outside the document bounds.
+ if (line > state.doc.lines) {
+ continue;
+ }
+
+ if (isSelected) {
+ decorations.push(selectedLineDecoration.range(state.doc.line(line).from));
+ } else {
+ decorations.push(lineDecoration.range(state.doc.line(line).from));
+ if (isHovered) {
+ decorations.push(hoverLineDecoration.range(state.doc.line(line).from));
+ }
+ }
+
+ }
+ }
+
+ decorations.sort((a, b) => a.from - b.from);
+ return Decoration.set(decorations);
+ },
+ update(deco, tr) {
+ return deco.map(tr.changes);
+ },
+ provide: (field) => EditorView.decorations.from(field),
+ }),
+ EditorView.domEventHandlers({
+ click: (event, view) => {
+ const reference = getReferenceAtPos(event.clientX, event.clientY, view);
+
+ if (reference) {
+ onSelectedReferenceChanged(reference.id === selectedReference?.id ? undefined : reference);
+ return true; // prevent default handling
+ }
+ return false;
+ },
+ mouseover: (event, view) => {
+ const reference = getReferenceAtPos(event.clientX, event.clientY, view);
+ if (!reference) {
+ return false;
+ }
+
+ if (reference.id === selectedReference?.id || reference.id === hoveredReference?.id) {
+ return false;
+ }
+
+ onHoveredReferenceChanged(reference);
+ return true;
+ },
+ mouseout: (event, view) => {
+ const reference = getReferenceAtPos(event.clientX, event.clientY, view);
+ if (reference) {
+ return false;
+ }
+
+ onHoveredReferenceChanged(undefined);
+ return true;
+ }
+ })
+ ];
+ }, [
+ languageExtension,
+ keymapExtension,
+ hasCodeNavEntitlement,
+ references,
+ hoveredReference?.id,
+ selectedReference?.id,
+ getReferenceAtPos,
+ onSelectedReferenceChanged,
+ onHoveredReferenceChanged,
+ codeFoldingExtension,
+ ]);
+
+ const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
+ if (symbolDefinitions.length === 0) {
+ return;
+ }
+
+ captureEvent('wa_goto_definition_pressed', {
+ source: 'chat',
+ });
+ createAuditAction({
+ action: "user.performed_goto_definition",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain);
+
+ if (symbolDefinitions.length === 1) {
+ const symbolDefinition = symbolDefinitions[0];
+ const { fileName, repoName } = symbolDefinition;
+
+ navigateToPath({
+ repoName,
+ revisionName: revision,
+ path: fileName,
+ pathType: 'blob',
+ highlightRange: symbolDefinition.range,
+ })
+ } else {
+ navigateToPath({
+ repoName,
+ revisionName: revision,
+ path: fileName,
+ pathType: 'blob',
+ setBrowseState: {
+ selectedSymbolInfo: {
+ symbolName,
+ repoName,
+ revisionName: revision,
+ language: language,
+ },
+ activeExploreMenuTab: "definitions",
+ isBottomPanelCollapsed: false,
+ }
+ });
+
+ }
+ }, [captureEvent, domain, navigateToPath, revision, repoName, fileName, language]);
+
+ const onFindReferences = useCallback((symbolName: string) => {
+ captureEvent('wa_find_references_pressed', {
+ source: 'chat',
+ });
+ createAuditAction({
+ action: "user.performed_find_references",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain);
+
+ navigateToPath({
+ repoName,
+ revisionName: revision,
+ path: fileName,
+ pathType: 'blob',
+ setBrowseState: {
+ selectedSymbolInfo: {
+ symbolName,
+ repoName,
+ revisionName: revision,
+ language: language,
+ },
+ activeExploreMenuTab: "references",
+ isBottomPanelCollapsed: false,
+ }
+ })
+
+ }, [captureEvent, domain, fileName, language, navigateToPath, repoName, revision]);
+
+ const ExpandCollapseIcon = useMemo(() => {
+ return isExpanded ? ChevronDown : ChevronRight;
+ }, [isExpanded]);
+
+ return (
+
+ {/* Sentinel element to scroll to when collapsing a file */}
+
+ {/* Sticky header outside the bordered container */}
+
+
onExpandedChanged(!isExpanded)} />
+
+
+
+ {/* Code container */}
+ {/* @note: don't conditionally render here since we want to maintain state */}
+
+
+ {editorRef && hasCodeNavEntitlement && (
+
+ )}
+
+
+
+ )
+}
+
+export default forwardRef(ReferencedFileSourceListItem) as (
+ props: ReferencedFileSourceListItemProps & { ref?: Ref },
+) => ReturnType;
diff --git a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx
new file mode 100644
index 00000000..c40f9fb9
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx
@@ -0,0 +1,286 @@
+'use client';
+
+import { fetchFileSource } from "@/app/api/(client)/client";
+import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDomain } from "@/hooks/useDomain";
+import { isServiceError, unwrapServiceError } from "@/lib/utils";
+import { useQueries } from "@tanstack/react-query";
+import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import scrollIntoView from 'scroll-into-view-if-needed';
+import { FileReference, FileSource, Reference, Source } from "../../types";
+import ReferencedFileSourceListItem from "./referencedFileSourceListItem";
+
+interface ReferencedSourcesListViewProps {
+ references: FileReference[];
+ sources: Source[];
+ index: number;
+ hoveredReference?: Reference;
+ onHoveredReferenceChanged: (reference?: Reference) => void;
+ selectedReference?: Reference;
+ onSelectedReferenceChanged: (reference?: Reference) => void;
+ style: React.CSSProperties;
+}
+
+const resolveFileReference = (reference: FileReference, sources: FileSource[]): FileSource | undefined => {
+ return sources.find(
+ (source) => source.repo.endsWith(reference.repo) &&
+ source.path.endsWith(reference.path)
+ );
+}
+
+export const ReferencedSourcesListView = ({
+ references,
+ sources,
+ index,
+ hoveredReference,
+ selectedReference,
+ style,
+ onHoveredReferenceChanged,
+ onSelectedReferenceChanged,
+}: ReferencedSourcesListViewProps) => {
+ const scrollAreaRef = useRef(null);
+ const editorRefsMap = useRef>(new Map());
+ const domain = useDomain();
+ const [collapsedFileIds, setCollapsedFileIds] = useState([]);
+
+ const getFileId = useCallback((fileSource: FileSource) => {
+ // @note: we include the index to ensure that the file id is unique
+ // across other ReferencedSourcesListView components in the
+ // same thread.
+ return `file-source-${fileSource.repo}-${fileSource.path}-${index}`;
+ }, [index]);
+
+ const setEditorRef = useCallback((fileKey: string, ref: ReactCodeMirrorRef | null) => {
+ if (ref) {
+ editorRefsMap.current.set(fileKey, ref);
+ } else {
+ editorRefsMap.current.delete(fileKey);
+ }
+ }, []);
+
+ const referencedFileSources = useMemo((): FileSource[] => {
+ const fileSources = sources.filter((source) => source.type === 'file');
+
+ return references
+ .filter((reference) => reference.type === 'file')
+ .map((reference) => resolveFileReference(reference, fileSources))
+ .filter((file) => file !== undefined)
+ // de-duplicate files
+ .filter((file, index, self) =>
+ index === self.findIndex((t) =>
+ t?.path === file?.path
+ && t?.repo === file?.repo
+ && t?.revision === file?.revision
+ )
+ );
+ }, [references, sources]);
+
+ // Memoize the computation of references grouped by file source
+ const referencesGroupedByFile = useMemo(() => {
+ const groupedReferences = new Map();
+
+ for (const fileSource of referencedFileSources) {
+ const fileKey = getFileId(fileSource);
+ const referencesInFile = references.filter((reference) => {
+ if (reference.type !== 'file') {
+ return false;
+ }
+ return resolveFileReference(reference, [fileSource]) !== undefined;
+ });
+ groupedReferences.set(fileKey, referencesInFile);
+ }
+
+ return groupedReferences;
+ }, [references, referencedFileSources, getFileId]);
+
+ const fileSourceQueries = useQueries({
+ queries: referencedFileSources.map((file) => ({
+ queryKey: ['fileSource', file.path, file.repo, file.revision, domain],
+ queryFn: () => unwrapServiceError(fetchFileSource({
+ fileName: file.path,
+ repository: file.repo,
+ branch: file.revision,
+ }, domain)),
+ staleTime: Infinity,
+ })),
+ });
+
+
+ useEffect(() => {
+ if (!selectedReference || selectedReference.type !== 'file') {
+ return;
+ }
+
+ const fileSource = resolveFileReference(selectedReference, referencedFileSources);
+ if (!fileSource) {
+ return;
+ }
+
+ const fileId = getFileId(fileSource);
+
+ const fileSourceElement = document.getElementById(fileId);
+
+ if (!fileSourceElement) {
+ return;
+ }
+
+ const editorRef = editorRefsMap.current.get(fileId);
+ const scrollAreaViewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null;
+
+ // If we have a range, we can scroll to the starting line number.
+ if (
+ selectedReference.range &&
+ editorRef &&
+ editorRef.view &&
+ scrollAreaViewport &&
+ selectedReference.range.startLine <= editorRef.view.state.doc.lines
+ ) {
+ const view = editorRef.view;
+ const lineNumber = selectedReference.range.startLine;
+
+ // Get the line's position within the CodeMirror document
+ const pos = view.state.doc.line(lineNumber).from;
+ const blockInfo = view.lineBlockAt(pos);
+ const lineTopInCodeMirror = blockInfo.top;
+
+ // Get the bounds of both elements
+ const viewportRect = scrollAreaViewport.getBoundingClientRect();
+ const codeMirrorRect = view.dom.getBoundingClientRect();
+
+ // Calculate the line's position relative to the ScrollArea content
+ const lineTopRelativeToScrollArea = lineTopInCodeMirror + (codeMirrorRect.top - viewportRect.top) + scrollAreaViewport.scrollTop;
+
+ // Get the height of the visible ScrollArea
+ const scrollAreaHeight = scrollAreaViewport.clientHeight;
+
+ // Calculate the target scroll position to center the line
+ const targetScrollTop = lineTopRelativeToScrollArea - (scrollAreaHeight / 3);
+
+ // Expand the file if it's collapsed.
+ setCollapsedFileIds((collapsedFileIds) => collapsedFileIds.filter((id) => id !== fileId));
+
+ // Scroll to the calculated position
+ // @NOTE: Using requestAnimationFrame is a bit of a hack to ensure
+ // that the collapsed file ids state has updated before scrolling.
+ requestAnimationFrame(() => {
+ scrollAreaViewport.scrollTo({
+ top: Math.max(0, targetScrollTop),
+ behavior: 'smooth',
+ });
+ });
+ }
+
+ // Otherwise, fallback to scrolling to the top of the file.
+ else {
+ scrollIntoView(fileSourceElement, {
+ scrollMode: 'if-needed',
+ block: 'start',
+ behavior: 'smooth',
+ });
+ }
+ }, [getFileId, referencedFileSources, selectedReference]);
+
+ if (referencedFileSources.length === 0) {
+ return (
+
+ No file references found
+
+ );
+ }
+
+ return (
+
+
+ {fileSourceQueries.map((query, index) => {
+ const fileSource = referencedFileSources[index];
+ const fileName = fileSource.path.split('/').pop() ?? fileSource.path;
+
+ if (query.isLoading) {
+ return (
+
+ );
+ }
+
+ if (query.isError || isServiceError(query.data)) {
+ return (
+
+
+
+ {fileName}
+
+
+ Failed to load file: {isServiceError(query.data) ? query.data.message : query.error?.message ?? 'Unknown error'}
+
+
+ );
+ }
+
+ const fileData = query.data!;
+
+ const fileId = getFileId(fileSource);
+ const referencesInFile = referencesGroupedByFile.get(fileId) || [];
+
+ return (
+
{
+ setEditorRef(fileId, ref);
+ }}
+ onSelectedReferenceChanged={onSelectedReferenceChanged}
+ onHoveredReferenceChanged={onHoveredReferenceChanged}
+ selectedReference={selectedReference}
+ hoveredReference={hoveredReference}
+ isExpanded={!collapsedFileIds.includes(fileId)}
+ onExpandedChanged={(isExpanded) => {
+ if (isExpanded) {
+ setCollapsedFileIds(collapsedFileIds.filter((id) => id !== fileId));
+ } else {
+ setCollapsedFileIds([...collapsedFileIds, fileId]);
+ }
+
+ // When collapsing a file when you are deep in a scroll, it's a better
+ // experience to have the scroll automatically restored to the top of the file
+ // s.t., header is still sticky to the top of the scroll area.
+ if (!isExpanded) {
+ const fileSourceStart = document.getElementById(`${fileId}-start`);
+ if (!fileSourceStart) {
+ return;
+ }
+
+ scrollIntoView(fileSourceStart, {
+ scrollMode: 'if-needed',
+ block: 'start',
+ behavior: 'instant',
+ });
+ }
+ }
+ }
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/web/src/features/chat/components/chatThread/tableOfContents.tsx b/packages/web/src/features/chat/components/chatThread/tableOfContents.tsx
new file mode 100644
index 00000000..c450a1b6
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tableOfContents.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+interface TocItem {
+ id: string;
+ text: string;
+ level: number;
+ element: HTMLElement;
+}
+
+interface TableOfContentsProps {
+ tocItems: TocItem[];
+ activeId: string;
+ className?: string;
+}
+
+export const TableOfContents = ({ tocItems, activeId, className }: TableOfContentsProps) => {
+ const scrollToHeading = (id: string) => {
+ const element = document.getElementById(id);
+ if (element) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }
+ };
+
+ return (
+
+ {tocItems.map((item) => (
+ scrollToHeading(item.id)}
+ className={cn(
+ 'w-full justify-start text-left h-auto py-0.5 px-0 font-normal text-wrap hover:text-foreground underline-offset-2 text-xs',
+ {
+ 'text-foreground': activeId === item.id,
+ 'text-muted-foreground': activeId !== item.id,
+ }
+ )}
+ style={{
+ paddingLeft: `${(item.level - 1) * 8 + 0}px`
+ }}
+ >
+ {item.text}
+
+ ))}
+
+ );
+};
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx
new file mode 100644
index 00000000..792efd43
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { FindSymbolDefinitionsToolUIPart } from "@/features/chat/tools";
+import { isServiceError } from "@/lib/utils";
+import { useMemo, useState } from "react";
+import { FileListItem, ToolHeader, TreeList } from "./shared";
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { BookOpenIcon } from "lucide-react";
+
+
+export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolDefinitionsToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Resolving definition...';
+ case 'input-available':
+ return Resolving definition for {part.input.symbol} ;
+ case 'output-error':
+ return '"Find symbol definitions" tool call failed';
+ case 'output-available':
+ return Resolved definition for {part.input.symbol} ;
+ }
+ }, [part]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+ {isServiceError(part.output) ? (
+
+ Failed with the following error: {part.output.message}
+
+ ) : (
+ <>
+ {part.output.length === 0 ? (
+ No matches found
+ ) : (
+
+ {part.output.map((file) => {
+ return (
+
+ )
+ })}
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx
new file mode 100644
index 00000000..44dcf763
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { FindSymbolReferencesToolUIPart } from "@/features/chat/tools";
+import { isServiceError } from "@/lib/utils";
+import { useMemo, useState } from "react";
+import { FileListItem, ToolHeader, TreeList } from "./shared";
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { BookOpenIcon } from "lucide-react";
+
+
+export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolReferencesToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Resolving references...';
+ case 'input-available':
+ return Resolving references for {part.input.symbol} ;
+ case 'output-error':
+ return '"Find symbol references" tool call failed';
+ case 'output-available':
+ return Resolved references for {part.input.symbol} ;
+ }
+ }, [part]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+ {isServiceError(part.output) ? (
+
+ Failed with the following error: {part.output.message}
+
+ ) : (
+ <>
+ {part.output.length === 0 ? (
+ No matches found
+ ) : (
+
+ {part.output.map((file) => {
+ return (
+
+ )
+ })}
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/tools/listAllReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listAllReposToolComponent.tsx
new file mode 100644
index 00000000..6c06146c
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/listAllReposToolComponent.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { ListAllReposToolUIPart } from "@/features/chat/tools";
+import { isServiceError } from "@/lib/utils";
+import { useMemo, useState } from "react";
+import { ToolHeader, TreeList } from "./shared";
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { FolderOpenIcon } from "lucide-react";
+
+export const ListAllReposToolComponent = ({ part }: { part: ListAllReposToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Loading all repositories...';
+ case 'output-error':
+ return '"List all repositories" tool call failed';
+ case 'input-available':
+ case 'output-available':
+ return 'Listed all repositories';
+ }
+ }, [part]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+ {isServiceError(part.output) ? (
+
+ Failed with the following error: {part.output.message}
+
+ ) : (
+ <>
+ {part.output.length === 0 ? (
+
No repositories found
+ ) : (
+
+
+ Found {part.output.length} repositories:
+
+ {part.output.map((repoName, index) => (
+
+
+ {repoName}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx
new file mode 100644
index 00000000..a31ae75b
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { ReadFilesToolUIPart } from "@/features/chat/tools";
+import { isServiceError } from "@/lib/utils";
+import { EyeIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+import { FileListItem, ToolHeader, TreeList } from "./shared";
+
+export const ReadFilesToolComponent = ({ part }: { part: ReadFilesToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Reading...';
+ case 'input-available':
+ return `Reading ${part.input.paths.length} file${part.input.paths.length === 1 ? '' : 's'}...`;
+ case 'output-error':
+ return 'Tool call failed';
+ case 'output-available':
+ if (isServiceError(part.output)) {
+ return 'Failed to read files';
+ }
+ return `Read ${part.output.length} file${part.output.length === 1 ? '' : 's'}`;
+ }
+ }, [part]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+
+ {isServiceError(part.output) ? (
+ Failed with the following error: {part.output.message}
+ ) : part.output.map((file) => {
+ return (
+
+ )
+ })}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx
new file mode 100644
index 00000000..9131d8cc
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { SearchCodeToolUIPart } from "@/features/chat/tools";
+import { useDomain } from "@/hooks/useDomain";
+import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
+import { useMemo, useState } from "react";
+import { FileListItem, ToolHeader, TreeList } from "./shared";
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { SearchIcon } from "lucide-react";
+import Link from "next/link";
+import { SearchQueryParams } from "@/lib/types";
+import { PlayIcon } from "@radix-ui/react-icons";
+import { buildSearchQuery } from "@/features/chat/utils";
+
+export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const domain = useDomain();
+
+ const displayQuery = useMemo(() => {
+ if (part.state !== 'input-available' && part.state !== 'output-available') {
+ return '';
+ }
+
+ const query = buildSearchQuery({
+ query: part.input.queryRegexp,
+ repoNamesFilterRegexp: part.input.repoNamesFilterRegexp,
+ languageNamesFilter: part.input.languageNamesFilter,
+ fileNamesFilterRegexp: part.input.fileNamesFilterRegexp,
+ });
+
+ return query;
+ }, [part]);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Searching...';
+ case 'output-error':
+ return '"Search code" tool call failed';
+ case 'input-available':
+ case 'output-available':
+ return Searched for {displayQuery} ;
+ }
+ }, [part, displayQuery]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+ {isServiceError(part.output) ? (
+
+ Failed with the following error: {part.output.message}
+
+ ) : (
+ <>
+ {part.output.files.length === 0 ? (
+
No matches found
+ ) : (
+
+ {part.output.files.map((file) => {
+ return (
+
+ )
+ })}
+
+ )}
+
+
+ Manually run query
+
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/chatThread/tools/searchReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/searchReposToolComponent.tsx
new file mode 100644
index 00000000..218cdba4
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/searchReposToolComponent.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { SearchReposToolUIPart } from "@/features/chat/tools";
+import { isServiceError } from "@/lib/utils";
+import { useMemo, useState } from "react";
+import { ToolHeader, TreeList } from "./shared";
+import { CodeSnippet } from "@/app/components/codeSnippet";
+import { Separator } from "@/components/ui/separator";
+import { BookMarkedIcon } from "lucide-react";
+
+export const SearchReposToolComponent = ({ part }: { part: SearchReposToolUIPart }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const label = useMemo(() => {
+ switch (part.state) {
+ case 'input-streaming':
+ return 'Searching repositories...';
+ case 'output-error':
+ return '"Search repositories" tool call failed';
+ case 'input-available':
+ case 'output-available':
+ return Searched for repositories: {part.input.query} ;
+ }
+ }, [part]);
+
+ return (
+
+
+ {part.state === 'output-available' && isExpanded && (
+ <>
+ {isServiceError(part.output) ? (
+
+ Failed with the following error: {part.output.message}
+
+ ) : (
+ <>
+ {part.output.length === 0 ? (
+
No repositories found
+ ) : (
+
+ {part.output.map((repoName, index) => (
+
+
+ {repoName}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx
new file mode 100644
index 00000000..a3e6be86
--- /dev/null
+++ b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { VscodeFileIcon } from '@/app/components/vscodeFileIcon';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useDomain } from '@/hooks/useDomain';
+import { cn } from '@/lib/utils';
+import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
+import Link from 'next/link';
+import React from 'react';
+import { getBrowsePath } from '@/app/[domain]/browse/hooks/useBrowseNavigation';
+
+
+export const FileListItem = ({
+ path,
+ repoName,
+}: {
+ path: string,
+ repoName: string,
+}) => {
+ const domain = useDomain();
+
+ return (
+
+
+
+ {path}
+
+
+ )
+}
+
+export const TreeList = ({ children }: { children: React.ReactNode }) => {
+ const childrenArray = React.Children.toArray(children);
+
+ return (
+
+ {/* vertical line */}
+ 0 ? `${100 / childrenArray.length * 0.6}%` : '0'
+ }}
+ />
+
+ {childrenArray.map((child, index) => {
+ const isLast = index === childrenArray.length - 1;
+
+ return (
+
+ {!isLast && (
+
+ )}
+ {isLast && (
+
+ )}
+
+
{child}
+
+ )
+ })}
+
+ );
+};
+
+interface ToolHeaderProps {
+ isLoading: boolean;
+ isError: boolean;
+ isExpanded: boolean;
+ label: React.ReactNode;
+ Icon: React.ElementType;
+ onExpand: (isExpanded: boolean) => void;
+ className?: string;
+}
+
+export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, className }: ToolHeaderProps) => {
+ return (
+
{
+ onExpand(!isExpanded)
+ }}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter") {
+ return;
+ }
+ onExpand(!isExpanded);
+ }}
+ >
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
{label}
+ {!isLoading && (
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/components/searchScopeIcon.tsx b/packages/web/src/features/chat/components/searchScopeIcon.tsx
new file mode 100644
index 00000000..933471f4
--- /dev/null
+++ b/packages/web/src/features/chat/components/searchScopeIcon.tsx
@@ -0,0 +1,32 @@
+import { cn, getCodeHostIcon } from "@/lib/utils";
+import { FolderIcon, LibraryBigIcon } from "lucide-react";
+import Image from "next/image";
+import { SearchScope } from "../types";
+
+interface SearchScopeIconProps {
+ searchScope: SearchScope;
+ className?: string;
+}
+
+export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchScopeIconProps) => {
+ if (searchScope.type === 'reposet') {
+ return
;
+ } else {
+ // Render code host icon for repos
+ const codeHostIcon = searchScope.codeHostType ? getCodeHostIcon(searchScope.codeHostType) : null;
+ if (codeHostIcon) {
+ const size = className.includes('h-3') ? 12 : 16;
+ return (
+
+ );
+ } else {
+ return
;
+ }
+ }
+};
\ No newline at end of file
diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts
new file mode 100644
index 00000000..c9da5694
--- /dev/null
+++ b/packages/web/src/features/chat/constants.ts
@@ -0,0 +1,31 @@
+import { SBChatMessagePart } from "./types";
+
+export const FILE_REFERENCE_PREFIX = '@file:';
+export const FILE_REFERENCE_REGEX = new RegExp(
+ // @file:{repoName::fileName:startLine-endLine}
+ `${FILE_REFERENCE_PREFIX}\\{([^:}]+)::([^:}]+)(?::(\\d+)(?:-(\\d+))?)?\\}`,
+ 'g'
+);
+
+export const ANSWER_TAG = '';
+
+export const toolNames = {
+ searchCode: 'searchCode',
+ readFiles: 'readFiles',
+ findSymbolReferences: 'findSymbolReferences',
+ findSymbolDefinitions: 'findSymbolDefinitions',
+ searchRepos: 'searchRepos',
+ listAllRepos: 'listAllRepos',
+} as const;
+
+// These part types are visible in the UI.
+export const uiVisiblePartTypes: SBChatMessagePart['type'][] = [
+ 'reasoning',
+ 'text',
+ 'tool-searchCode',
+ 'tool-readFiles',
+ 'tool-findSymbolDefinitions',
+ 'tool-findSymbolReferences',
+ 'tool-searchRepos',
+ 'tool-listAllRepos',
+] as const;
\ No newline at end of file
diff --git a/packages/web/src/features/chat/customSlateEditor.tsx b/packages/web/src/features/chat/customSlateEditor.tsx
new file mode 100644
index 00000000..4f52f11a
--- /dev/null
+++ b/packages/web/src/features/chat/customSlateEditor.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { Slate } from "slate-react";
+import { useCustomSlateEditor } from "./useCustomSlateEditor";
+import { CustomElement } from "./types";
+
+interface CustomSlateEditorProps {
+ children: React.ReactNode;
+}
+
+const initialValue: CustomElement[] = [
+ {
+ type: 'paragraph',
+ children: [{ text: '' }],
+ },
+];
+
+export const CustomSlateEditor = ({ children }: CustomSlateEditorProps) => {
+ const editor = useCustomSlateEditor();
+
+ return
+ {children}
+ ;
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts
new file mode 100644
index 00000000..f69c5f34
--- /dev/null
+++ b/packages/web/src/features/chat/tools.ts
@@ -0,0 +1,271 @@
+import { z } from "zod"
+import { search } from "@/features/search/searchApi"
+import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
+import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai";
+import { isServiceError } from "@/lib/utils";
+import { getFileSource } from "../search/fileSourceApi";
+import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "../codeNav/actions";
+import { FileSourceResponse } from "../search/types";
+import { addLineNumbers, buildSearchQuery } from "./utils";
+import { toolNames } from "./constants";
+import { getRepos } from "@/actions";
+import Fuse from "fuse.js";
+
+// @NOTE: When adding a new tool, follow these steps:
+// 1. Add the tool to the `toolNames` constant in `constants.ts`.
+// 2. Add the tool to the `SBChatMessageToolTypes` type in `types.ts`.
+// 3. Add the tool to the `tools` prop in `agent.ts`.
+// 4. If the tool is meant to be rendered in the UI:
+// - Add the tool to the `uiVisiblePartTypes` constant in `constants.ts`.
+// - Add the tool's component to the `DetailsCard` switch statement in `detailsCard.tsx`.
+//
+// - bk, 2025-07-25
+
+
+export const findSymbolReferencesTool = tool({
+ description: `Finds references to a symbol in the codebase.`,
+ inputSchema: z.object({
+ symbol: z.string().describe("The symbol to find references to"),
+ language: z.string().describe("The programming language of the symbol"),
+ }),
+ execute: async ({ symbol, language }) => {
+ // @todo: make revision configurable.
+ const revision = "HEAD";
+
+ const response = await findSearchBasedSymbolReferences({
+ symbolName: symbol,
+ language,
+ revisionName: "HEAD",
+ // @todo(mt): handle multi-tenancy.
+ }, SINGLE_TENANT_ORG_DOMAIN);
+
+ if (isServiceError(response)) {
+ return response;
+ }
+
+ return response.files.map((file) => ({
+ fileName: file.fileName,
+ repository: file.repository,
+ language: file.language,
+ matches: file.matches.map(({ lineContent, range }) => {
+ return addLineNumbers(lineContent, range.start.lineNumber);
+ }),
+ revision,
+ }));
+ },
+});
+
+export type FindSymbolReferencesTool = InferUITool
;
+export type FindSymbolReferencesToolInput = InferToolInput;
+export type FindSymbolReferencesToolOutput = InferToolOutput;
+export type FindSymbolReferencesToolUIPart = ToolUIPart<{ [toolNames.findSymbolReferences]: FindSymbolReferencesTool }>
+
+export const findSymbolDefinitionsTool = tool({
+ description: `Finds definitions of a symbol in the codebase.`,
+ inputSchema: z.object({
+ symbol: z.string().describe("The symbol to find definitions of"),
+ language: z.string().describe("The programming language of the symbol"),
+ }),
+ execute: async ({ symbol, language }) => {
+ // @todo: make revision configurable.
+ const revision = "HEAD";
+
+ const response = await findSearchBasedSymbolDefinitions({
+ symbolName: symbol,
+ language,
+ revisionName: revision,
+ // @todo(mt): handle multi-tenancy.
+ }, SINGLE_TENANT_ORG_DOMAIN);
+
+ if (isServiceError(response)) {
+ return response;
+ }
+
+ return response.files.map((file) => ({
+ fileName: file.fileName,
+ repository: file.repository,
+ language: file.language,
+ matches: file.matches.map(({ lineContent, range }) => {
+ return addLineNumbers(lineContent, range.start.lineNumber);
+ }),
+ revision,
+ }));
+ }
+});
+
+export type FindSymbolDefinitionsTool = InferUITool;
+export type FindSymbolDefinitionsToolInput = InferToolInput;
+export type FindSymbolDefinitionsToolOutput = InferToolOutput;
+export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool }>
+
+export const readFilesTool = tool({
+ description: `Reads the contents of multiple files at the given paths.`,
+ inputSchema: z.object({
+ paths: z.array(z.string()).describe("The paths to the files to read"),
+ repository: z.string().describe("The repository to read the files from"),
+ }),
+ execute: async ({ paths, repository }) => {
+ // @todo: make revision configurable.
+ const revision = "HEAD";
+
+ const responses = await Promise.all(paths.map(async (path) => {
+ return getFileSource({
+ fileName: path,
+ repository,
+ branch: revision,
+ // @todo(mt): handle multi-tenancy.
+ });
+ }));
+
+ if (responses.some(isServiceError)) {
+ const firstError = responses.find(isServiceError);
+ return firstError!;
+ }
+
+ return (responses as FileSourceResponse[]).map((response) => ({
+ path: response.path,
+ repository: response.repository,
+ language: response.language,
+ source: addLineNumbers(response.source),
+ revision,
+ }));
+ }
+});
+
+export type ReadFilesTool = InferUITool;
+export type ReadFilesToolInput = InferToolInput;
+export type ReadFilesToolOutput = InferToolOutput;
+export type ReadFilesToolUIPart = ToolUIPart<{ [toolNames.readFiles]: ReadFilesTool }>
+
+export const createCodeSearchTool = (selectedRepos: string[]) => tool({
+ description: `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search.
+ Results are returned as an array of matching files, with the file's URL, repository, and language.`,
+ inputSchema: z.object({
+ queryRegexp: z
+ .string()
+ .describe(`The regex pattern to search for in the code.
+
+Queries consist of space-seperated regular expressions. Wrapping expressions in "" combines them. By default, a file must have at least one match for each expression to be included. Examples:
+
+\`foo\` - Match files with regex /foo/
+\`foo bar\` - Match files with regex /foo/ and /bar/
+\`"foo bar"\` - Match files with regex /foo bar/
+\`console.log\` - Match files with regex /console.log/
+
+Multiple expressions can be or'd together with or, negated with -, or grouped with (). Examples:
+\`foo or bar\` - Match files with regex /foo/ or /bar/
+\`foo -bar\` - Match files with regex /foo/ but not /bar/
+\`foo (bar or baz)\` - Match files with regex /foo/ and either /bar/ or /baz/
+`),
+ repoNamesFilterRegexp: z
+ .array(z.string())
+ .describe(`Filter results from repos that match the regex. By default all repos are searched.`)
+ .optional(),
+ languageNamesFilter: z
+ .array(z.string())
+ .describe(`Scope the search to the provided languages. The language MUST be formatted as a GitHub linguist language. Examples: Python, JavaScript, TypeScript, Java, C#, C++, PHP, Go, Rust, Ruby, Swift, Kotlin, Shell, C, Dart, HTML, CSS, PowerShell, SQL, R`)
+ .optional(),
+ fileNamesFilterRegexp: z
+ .array(z.string())
+ .describe(`Filter results from filepaths that match the regex. When this option is not specified, all files are searched.`)
+ .optional(),
+ limit: z.number().default(10).describe("Maximum number of matches to return (default: 100)"),
+ }),
+ execute: async ({ queryRegexp: _query, repoNamesFilterRegexp, languageNamesFilter, fileNamesFilterRegexp, limit }) => {
+ const query = buildSearchQuery({
+ query: _query,
+ repoNamesFilter: selectedRepos,
+ repoNamesFilterRegexp,
+ languageNamesFilter,
+ fileNamesFilterRegexp,
+ });
+
+ const response = await search({
+ query,
+ matches: limit ?? 100,
+ // @todo: we can make this configurable.
+ contextLines: 3,
+ whole: false,
+ // @todo(mt): handle multi-tenancy.
+ });
+
+ if (isServiceError(response)) {
+ return response;
+ }
+
+ return {
+ files: response.files.map((file) => ({
+ fileName: file.fileName.text,
+ repository: file.repository,
+ language: file.language,
+ matches: file.chunks.map(({ content, contentStart }) => {
+ return addLineNumbers(content, contentStart.lineNumber);
+ }),
+ // @todo: make revision configurable.
+ revision: 'HEAD',
+ })),
+ query,
+ }
+ },
+});
+
+export type SearchCodeTool = InferUITool>;
+export type SearchCodeToolInput = InferToolInput>;
+export type SearchCodeToolOutput = InferToolOutput>;
+export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>;
+
+export const searchReposTool = tool({
+ description: `Search for repositories by name using fuzzy search. This helps find repositories in the codebase when you know part of their name.`,
+ inputSchema: z.object({
+ query: z.string().describe("The search query to find repositories by name (supports fuzzy matching)"),
+ limit: z.number().default(10).describe("Maximum number of repositories to return (default: 10)")
+ }),
+ execute: async ({ query, limit }) => {
+ const reposResponse = await getRepos();
+
+ if (isServiceError(reposResponse)) {
+ return reposResponse;
+ }
+
+ // Configure Fuse.js for fuzzy searching
+ const fuse = new Fuse(reposResponse, {
+ keys: [
+ { name: 'repoName', weight: 0.7 },
+ { name: 'repoDisplayName', weight: 0.3 }
+ ],
+ threshold: 0.4, // Lower threshold = more strict matching
+ includeScore: true,
+ minMatchCharLength: 1,
+ });
+
+ const searchResults = fuse.search(query, { limit: limit ?? 10 });
+
+ searchResults.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
+
+ return searchResults.map(({ item }) => item.repoName);
+ }
+});
+
+export type SearchReposTool = InferUITool;
+export type SearchReposToolInput = InferToolInput;
+export type SearchReposToolOutput = InferToolOutput;
+export type SearchReposToolUIPart = ToolUIPart<{ [toolNames.searchRepos]: SearchReposTool }>;
+
+export const listAllReposTool = tool({
+ description: `Lists all repositories in the codebase. This provides a complete overview of all available repositories.`,
+ inputSchema: z.object({}),
+ execute: async () => {
+ const reposResponse = await getRepos();
+
+ if (isServiceError(reposResponse)) {
+ return reposResponse;
+ }
+
+ return reposResponse.map((repo) => repo.repoName);
+ }
+});
+
+export type ListAllReposTool = InferUITool;
+export type ListAllReposToolInput = InferToolInput;
+export type ListAllReposToolOutput = InferToolOutput;
+export type ListAllReposToolUIPart = ToolUIPart<{ [toolNames.listAllRepos]: ListAllReposTool }>;
diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts
new file mode 100644
index 00000000..524f14f8
--- /dev/null
+++ b/packages/web/src/features/chat/types.ts
@@ -0,0 +1,184 @@
+import { CreateUIMessage, UIMessage, UIMessagePart } from "ai";
+import { BaseEditor, Descendant } from "slate";
+import { HistoryEditor } from "slate-history";
+import { ReactEditor, RenderElementProps } from "slate-react";
+import { z } from "zod";
+import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFilesTool, SearchCodeTool, SearchReposTool, ListAllReposTool } from "./tools";
+import { toolNames } from "./constants";
+import { LanguageModel } from "@sourcebot/schemas/v3/index.type";
+
+const fileSourceSchema = z.object({
+ type: z.literal('file'),
+ repo: z.string(),
+ path: z.string(),
+ name: z.string(),
+ language: z.string(),
+ revision: z.string(),
+});
+export type FileSource = z.infer;
+
+export const sourceSchema = z.discriminatedUnion('type', [
+ fileSourceSchema,
+]);
+export type Source = z.infer;
+
+const fileReferenceSchema = z.object({
+ type: z.literal('file'),
+ id: z.string(),
+ repo: z.string(),
+ path: z.string(),
+ range: z.object({
+ startLine: z.number(),
+ endLine: z.number(),
+ }).optional(),
+});
+export type FileReference = z.infer;
+
+export const referenceSchema = z.discriminatedUnion('type', [
+ fileReferenceSchema,
+]);
+export type Reference = z.infer;
+
+export const repoSearchScopeSchema = z.object({
+ type: z.literal('repo'),
+ value: z.string(),
+ name: z.string(),
+ codeHostType: z.string(),
+});
+export type RepoSearchScope = z.infer;
+
+export const repoSetSearchScopeSchema = z.object({
+ type: z.literal('reposet'),
+ value: z.string(),
+ name: z.string(),
+ repoCount: z.number(),
+});
+export type RepoSetSearchScope = z.infer;
+
+export const searchScopeSchema = z.discriminatedUnion('type', [
+ repoSearchScopeSchema,
+ repoSetSearchScopeSchema,
+]);
+export type SearchScope = z.infer;
+
+export const sbChatMessageMetadataSchema = z.object({
+ modelName: z.string().optional(),
+ totalInputTokens: z.number().optional(),
+ totalOutputTokens: z.number().optional(),
+ totalTokens: z.number().optional(),
+ totalResponseTimeMs: z.number().optional(),
+ feedback: z.array(z.object({
+ type: z.enum(['like', 'dislike']),
+ timestamp: z.string(), // ISO date string
+ userId: z.string(),
+ })).optional(),
+ selectedSearchScopes: z.array(searchScopeSchema).optional(),
+ traceId: z.string().optional(),
+});
+
+export type SBChatMessageMetadata = z.infer;
+
+export type SBChatMessageToolTypes = {
+ [toolNames.searchCode]: SearchCodeTool,
+ [toolNames.readFiles]: ReadFilesTool,
+ [toolNames.findSymbolReferences]: FindSymbolReferencesTool,
+ [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool,
+ [toolNames.searchRepos]: SearchReposTool,
+ [toolNames.listAllRepos]: ListAllReposTool,
+}
+
+export type SBChatMessageDataParts = {
+ // The `source` data type allows us to know what sources the LLM saw
+ // during retrieval.
+ "source": Source,
+}
+
+export type SBChatMessage = UIMessage<
+ SBChatMessageMetadata,
+ SBChatMessageDataParts,
+ SBChatMessageToolTypes
+>;
+
+export type SBChatMessagePart = UIMessagePart<
+ SBChatMessageDataParts,
+ SBChatMessageToolTypes
+>;
+
+// Slate specific types //
+
+export type CustomText = { text: string; }
+
+export type ParagraphElement = {
+ type: 'paragraph'
+ align?: string
+ children: Descendant[];
+}
+
+export type FileMentionData = {
+ type: 'file';
+ repo: string;
+ path: string;
+ name: string;
+ language: string;
+ revision: string;
+}
+
+export type MentionData = FileMentionData;
+
+export type MentionElement = {
+ type: 'mention';
+ data: MentionData;
+ children: CustomText[];
+}
+
+export type CustomElement =
+ ParagraphElement |
+ MentionElement
+ ;
+
+
+export type CustomEditor =
+ BaseEditor &
+ ReactEditor &
+ HistoryEditor
+ ;
+
+export type RenderElementPropsFor = RenderElementProps & {
+ element: T
+}
+
+declare module 'slate' {
+ interface CustomTypes {
+ Editor: CustomEditor
+ Element: CustomElement
+ Text: CustomText
+ }
+}
+
+/////////////////////////
+
+// Misc //
+export const SET_CHAT_STATE_SESSION_STORAGE_KEY = 'setChatState';
+
+export type SetChatStatePayload = {
+ inputMessage: CreateUIMessage;
+ selectedSearchScopes: SearchScope[];
+}
+
+
+export type LanguageModelProvider = LanguageModel['provider'];
+
+// This is a subset of information about a configured
+// language model that we can safely send to the client.
+export type LanguageModelInfo = {
+ provider: LanguageModelProvider,
+ model: LanguageModel['model'],
+ displayName?: LanguageModel['displayName'],
+}
+
+// Additional request body data that we send along to the chat API.
+export const additionalChatRequestParamsSchema = z.object({
+ languageModelId: z.string(),
+ selectedSearchScopes: z.array(searchScopeSchema),
+});
+export type AdditionalChatRequestParams = z.infer;
\ No newline at end of file
diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts
new file mode 100644
index 00000000..25c67b22
--- /dev/null
+++ b/packages/web/src/features/chat/useCreateNewChatThread.ts
@@ -0,0 +1,60 @@
+'use client';
+
+import { useCallback, useState } from "react";
+import { Descendant } from "slate";
+import { createUIMessage, getAllMentionElements } from "./utils";
+import { slateContentToString } from "./utils";
+import { useDomain } from "@/hooks/useDomain";
+import { useToast } from "@/components/hooks/use-toast";
+import { useRouter } from "next/navigation";
+import { createChat } from "./actions";
+import { isServiceError } from "@/lib/utils";
+import { createPathWithQueryParams } from "@/lib/utils";
+import { SearchScope, SET_CHAT_STATE_SESSION_STORAGE_KEY, SetChatStatePayload } from "./types";
+import { useSessionStorage } from "usehooks-ts";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+
+export const useCreateNewChatThread = () => {
+ const domain = useDomain();
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+ const captureEvent = useCaptureEvent();
+
+ const [, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null);
+
+
+ const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => {
+ const text = slateContentToString(children);
+ const mentions = getAllMentionElements(children);
+
+ const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes);
+
+ setIsLoading(true);
+ const response = await createChat(domain);
+ if (isServiceError(response)) {
+ toast({
+ description: `❌ Failed to create chat. Reason: ${response.message}`
+ });
+ setIsLoading(false);
+ return;
+ }
+
+ captureEvent('wa_chat_thread_created', {});
+
+ setChatState({
+ inputMessage,
+ selectedSearchScopes,
+ });
+
+ const url = createPathWithQueryParams(`/${domain}/chat/${response.id}`);
+
+ router.push(url);
+ router.refresh();
+ }, [domain, router, toast, setChatState, captureEvent]);
+
+ return {
+ createNewChatThread,
+ isLoading,
+ };
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/useCustomSlateEditor.ts b/packages/web/src/features/chat/useCustomSlateEditor.ts
new file mode 100644
index 00000000..20c87147
--- /dev/null
+++ b/packages/web/src/features/chat/useCustomSlateEditor.ts
@@ -0,0 +1,37 @@
+'use client';
+
+import { createEditor } from "slate";
+import { useState } from "react";
+import { withReact } from "slate-react";
+import { withHistory } from "slate-history";
+import { CustomEditor } from "./types";
+import { Element } from "slate";
+
+export const useCustomSlateEditor = () => {
+ const [editor] = useState(() =>
+ withMentions(
+ withReact(
+ withHistory(createEditor())
+ )
+ )
+ );
+ return editor;
+}
+
+const withMentions = (editor: CustomEditor) => {
+ const { isInline, isVoid, markableVoid } = editor;
+
+ editor.isInline = (element: Element) => {
+ return element.type === 'mention' ? true : isInline(element)
+ }
+
+ editor.isVoid = (element: Element) => {
+ return element.type === 'mention' ? true : isVoid(element)
+ }
+
+ editor.markableVoid = (element: Element) => {
+ return element.type === 'mention' || markableVoid(element)
+ }
+
+ return editor
+}
diff --git a/packages/web/src/features/chat/useExtractReferences.test.ts b/packages/web/src/features/chat/useExtractReferences.test.ts
new file mode 100644
index 00000000..7208aba4
--- /dev/null
+++ b/packages/web/src/features/chat/useExtractReferences.test.ts
@@ -0,0 +1,40 @@
+import { expect, test } from 'vitest'
+import { renderHook } from '@testing-library/react';
+import { useExtractReferences } from './useExtractReferences';
+import { getFileReferenceId } from './utils';
+import { TextUIPart } from 'ai';
+
+test('useExtractReferences extracts file references from text content', () => {
+ const part: TextUIPart = {
+ type: 'text',
+ text: 'The auth flow is implemented in @file:{github.com/sourcebot-dev/sourcebot::auth.ts} and uses sessions @file:{github.com/sourcebot-dev/sourcebot::auth.ts:45-60}.'
+ }
+
+ const { result } = renderHook(() => useExtractReferences(part));
+
+ expect(result.current).toHaveLength(2);
+ expect(result.current[0]).toMatchObject({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts',
+ id: getFileReferenceId({ repo: 'github.com/sourcebot-dev/sourcebot', path: 'auth.ts' }),
+ type: 'file',
+ });
+
+ expect(result.current[1]).toMatchObject({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts',
+ id: getFileReferenceId({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts',
+ range: {
+ startLine: 45,
+ endLine: 60,
+ }
+ }),
+ type: 'file',
+ range: {
+ startLine: 45,
+ endLine: 60,
+ }
+ });
+});
diff --git a/packages/web/src/features/chat/useExtractReferences.ts b/packages/web/src/features/chat/useExtractReferences.ts
new file mode 100644
index 00000000..45ef173d
--- /dev/null
+++ b/packages/web/src/features/chat/useExtractReferences.ts
@@ -0,0 +1,36 @@
+'use client';
+
+import { useMemo } from "react";
+import { FileReference } from "./types";
+import { FILE_REFERENCE_REGEX } from "./constants";
+import { createFileReference } from "./utils";
+import { TextUIPart } from "ai";
+
+export const useExtractReferences = (part?: TextUIPart) => {
+ return useMemo(() => {
+ if (!part) {
+ return [];
+ }
+
+ const references: FileReference[] = [];
+
+ const content = part.text;
+ FILE_REFERENCE_REGEX.lastIndex = 0;
+
+ let match;
+ while ((match = FILE_REFERENCE_REGEX.exec(content ?? '')) !== null && match !== null) {
+ const [_, repo, fileName, startLine, endLine] = match;
+
+ const fileReference = createFileReference({
+ repo: repo,
+ path: fileName,
+ startLine,
+ endLine,
+ });
+
+ references.push(fileReference);
+ }
+
+ return references;
+ }, [part]);
+};
diff --git a/packages/web/src/features/chat/useMessagePairs.test.ts b/packages/web/src/features/chat/useMessagePairs.test.ts
new file mode 100644
index 00000000..a4417991
--- /dev/null
+++ b/packages/web/src/features/chat/useMessagePairs.test.ts
@@ -0,0 +1,117 @@
+import { expect, test } from 'vitest'
+import { SBChatMessage } from './types';
+import { useMessagePairs } from './useMessagePairs';
+import { renderHook } from '@testing-library/react';
+
+test('useMessagePairs pairs user and assistant messages', () => {
+ const userMessage: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '0'
+ }
+
+ const assistantMessage: SBChatMessage = {
+ role: 'assistant', parts: [],
+ id: '1'
+ }
+
+ const messages: SBChatMessage[] = [
+ userMessage,
+ assistantMessage,
+ ]
+
+ const pairs = renderHook(() => useMessagePairs(messages));
+
+ expect(pairs.result.current).toEqual([
+ [userMessage, assistantMessage],
+ ]);
+
+});
+
+test('pairMessages pairs orphaned user messages with undefined', () => {
+ const userMessage1: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '0'
+ }
+
+ const userMessage2: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '1'
+ }
+
+ const assistantMessage: SBChatMessage = {
+ role: 'assistant', parts: [],
+ id: '2'
+ }
+
+ const messages: SBChatMessage[] = [
+ userMessage1,
+ userMessage2,
+ assistantMessage,
+ ]
+
+ const pairs = renderHook(() => useMessagePairs(messages));
+
+ expect(pairs.result.current).toEqual([
+ [userMessage1, undefined],
+ [userMessage2, assistantMessage],
+ ]);
+});
+
+test('pairMessages ignores orphaned assistant messages', () => {
+ const userMessage: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '0'
+ }
+
+ const assistantMessage1: SBChatMessage = {
+ role: 'assistant', parts: [],
+ id: '1'
+ }
+
+ const assistantMessage2: SBChatMessage = {
+ role: 'assistant', parts: [],
+ id: '2'
+ }
+
+ const messages: SBChatMessage[] = [
+ userMessage,
+ assistantMessage1,
+ assistantMessage2,
+ ]
+
+ const pairs = renderHook(() => useMessagePairs(messages));
+
+ expect(pairs.result.current).toEqual([
+ [userMessage, assistantMessage1],
+ ]);
+})
+
+test('pairMessages pairs the last message with undefined if it is a user message', () => {
+ const userMessage1: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '0'
+ }
+
+ const assistantMessage: SBChatMessage = {
+ role: 'assistant', parts: [],
+ id: '2'
+ }
+
+ const userMessage2: SBChatMessage = {
+ role: 'user', parts: [],
+ id: '1'
+ }
+
+ const messages: SBChatMessage[] = [
+ userMessage1,
+ assistantMessage,
+ userMessage2,
+ ]
+
+ const pairs = renderHook(() => useMessagePairs(messages));
+
+ expect(pairs.result.current).toEqual([
+ [userMessage1, assistantMessage],
+ [userMessage2, undefined],
+ ]);
+})
\ No newline at end of file
diff --git a/packages/web/src/features/chat/useMessagePairs.ts b/packages/web/src/features/chat/useMessagePairs.ts
new file mode 100644
index 00000000..36ce7097
--- /dev/null
+++ b/packages/web/src/features/chat/useMessagePairs.ts
@@ -0,0 +1,43 @@
+'use client';
+
+import { useMemo } from "react";
+import { SBChatMessage } from "./types";
+
+// Pairs user messages with the assistant's response.
+export const useMessagePairs = (messages: SBChatMessage[]): [SBChatMessage, SBChatMessage | undefined][] => {
+ return useMemo(() => {
+ const result: [SBChatMessage, SBChatMessage | undefined][] = [];
+ let pendingUserMessage: SBChatMessage | null = null;
+
+ for (const message of messages) {
+ if (message.role === 'user') {
+ // case: we have a orphaned user message.
+ // Pair it with undefined.
+ if (pendingUserMessage) {
+ result.push([pendingUserMessage, undefined]);
+ }
+
+ pendingUserMessage = message;
+ } else if (message.role === 'assistant') {
+
+ // case: we have a user <> assistant message pair.
+ // Pair them.
+ if (pendingUserMessage) {
+ result.push([pendingUserMessage, message]);
+ pendingUserMessage = null;
+ }
+
+ // case: we have a orphaned assistant message.
+ // Ignore the orphaned assistant message.
+ }
+ }
+
+ // case: the last message is a user message.
+ // Pair it with undefined.
+ if (pendingUserMessage) {
+ result.push([pendingUserMessage, undefined]);
+ }
+
+ return result;
+ }, [messages]);
+};
\ No newline at end of file
diff --git a/packages/web/src/features/chat/useSelectedLanguageModel.ts b/packages/web/src/features/chat/useSelectedLanguageModel.ts
new file mode 100644
index 00000000..7cdc79e4
--- /dev/null
+++ b/packages/web/src/features/chat/useSelectedLanguageModel.ts
@@ -0,0 +1,25 @@
+'use client';
+
+import { useLocalStorage } from "usehooks-ts";
+import { LanguageModelInfo } from "./types";
+
+type Props = {
+ initialLanguageModel?: LanguageModelInfo;
+}
+
+export const useSelectedLanguageModel = ({
+ initialLanguageModel,
+}: Props = {}) => {
+ const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage(
+ "selectedLanguageModel",
+ initialLanguageModel,
+ {
+ initializeWithValue: false,
+ }
+ );
+
+ return {
+ selectedLanguageModel,
+ setSelectedLanguageModel,
+ };
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/useTOCItems.ts b/packages/web/src/features/chat/useTOCItems.ts
new file mode 100644
index 00000000..44ee62b8
--- /dev/null
+++ b/packages/web/src/features/chat/useTOCItems.ts
@@ -0,0 +1,100 @@
+'use client';
+
+import { useEffect, useRef, useState } from "react";
+
+interface Props {
+ target: HTMLElement | null;
+}
+
+export interface TocItem {
+ id: string;
+ text: string;
+ level: number;
+ element: HTMLElement;
+}
+
+
+export const useExtractTOCItems = ({ target }: Props) => {
+ const [tocItems, setTocItems] = useState([]);
+ const [activeId, setActiveId] = useState('');
+ const observerRef = useRef(null);
+
+ useEffect(() => {
+ const extractHeadings = () => {
+ if (!target) return;
+
+ const headings = target.querySelectorAll('h1, h2, h3, h4, h5, h6');
+ const items: TocItem[] = [];
+
+ headings.forEach((heading) => {
+ const element = heading as HTMLElement;
+ const level = parseInt(element.tagName.charAt(1));
+ const text = element.textContent?.trim() || '';
+
+ if (text && element.id) {
+ items.push({
+ id: element.id,
+ text,
+ level,
+ element
+ });
+ }
+ });
+
+ setTocItems(items);
+ };
+
+ // Initial extraction
+ extractHeadings();
+
+ // Set up MutationObserver to watch for DOM changes
+ if (target) {
+ const observer = new MutationObserver(() => {
+ extractHeadings();
+ });
+
+ observer.observe(target, {
+ childList: true,
+ subtree: true,
+ characterData: true
+ });
+
+ return () => observer.disconnect();
+ }
+ }, [target]);
+
+ // Set up intersection observer for active heading tracking
+ useEffect(() => {
+ if (tocItems.length === 0) return;
+
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setActiveId(entry.target.id);
+ }
+ });
+ },
+ {
+ rootMargin: '-20px 0px -80% 0px'
+ }
+ );
+
+ tocItems.forEach((item) => {
+ if (observerRef.current) {
+ observerRef.current.observe(item.element);
+ }
+ });
+
+ return () => {
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ }
+ };
+ }, [tocItems]);
+
+ return {
+ tocItems,
+ activeId
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/features/chat/utils.test.ts b/packages/web/src/features/chat/utils.test.ts
new file mode 100644
index 00000000..3ebd4e31
--- /dev/null
+++ b/packages/web/src/features/chat/utils.test.ts
@@ -0,0 +1,514 @@
+import { expect, test, vi } from 'vitest'
+import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, buildSearchQuery } from './utils'
+import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants';
+import { SBChatMessage, SBChatMessagePart } from './types';
+
+// Mock the env module
+vi.mock('@/env.mjs', () => ({
+ env: {
+ SOURCEBOT_CHAT_FILE_MAX_CHARACTERS: 4000,
+ }
+}));
+
+
+test('fileReferenceToString formats file references correctly', () => {
+ expect(fileReferenceToString({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts'
+ })).toBe('@file:{github.com/sourcebot-dev/sourcebot::auth.ts}');
+
+ expect(fileReferenceToString({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts',
+ range: {
+ startLine: 45,
+ endLine: 60,
+ }
+ })).toBe('@file:{github.com/sourcebot-dev/sourcebot::auth.ts:45-60}');
+});
+
+test('fileReferenceToString matches FILE_REFERENCE_REGEX', () => {
+ expect(FILE_REFERENCE_REGEX.test(fileReferenceToString({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts'
+ }))).toBe(true);
+
+ FILE_REFERENCE_REGEX.lastIndex = 0;
+ expect(FILE_REFERENCE_REGEX.test(fileReferenceToString({
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ path: 'auth.ts',
+ range: {
+ startLine: 45,
+ endLine: 60,
+ }
+ }))).toBe(true);
+});
+
+test('groupMessageIntoSteps returns an empty array when there are no parts', () => {
+ const parts: SBChatMessagePart[] = []
+
+ const steps = groupMessageIntoSteps(parts);
+
+ expect(steps).toEqual([]);
+});
+
+test('groupMessageIntoSteps returns a single group when there is only one step-start part', () => {
+ const parts: SBChatMessagePart[] = [
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ }
+ ]
+
+ const steps = groupMessageIntoSteps(parts);
+
+ expect(steps).toEqual([
+ [
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ }
+ ]
+ ]);
+});
+
+test('groupMessageIntoSteps returns a multiple groups when there is multiple step-start parts', () => {
+ const parts: SBChatMessagePart[] = [
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ },
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Ok lets go',
+ },
+ ]
+
+ const steps = groupMessageIntoSteps(parts);
+
+ expect(steps).toEqual([
+ [
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ }
+ ],
+ [
+ {
+ type: 'step-start',
+ },
+ {
+ type: 'text',
+ text: 'Ok lets go',
+ }
+ ]
+ ]);
+});
+
+test('groupMessageIntoSteps returns a single group when there is no step-start part', () => {
+ const parts: SBChatMessagePart[] = [
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ },
+ {
+ type: 'text',
+ text: 'Ok lets go',
+ },
+ ]
+
+ const steps = groupMessageIntoSteps(parts);
+
+ expect(steps).toEqual([
+ [
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ },
+ {
+ type: 'text',
+ text: 'Ok lets go',
+ }
+ ]
+ ]);
+});
+
+test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while not streaming', () => {
+ const message: SBChatMessage = {
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ text: 'Some initial text'
+ },
+ {
+ type: 'text',
+ text: `${ANSWER_TAG}This is the answer to your question.`
+ }
+ ]
+ } as SBChatMessage;
+
+ const result = getAnswerPartFromAssistantMessage(message, false);
+
+ expect(result).toEqual({
+ type: 'text',
+ text: `${ANSWER_TAG}This is the answer to your question.`
+ });
+});
+
+test('getAnswerPartFromAssistantMessage returns text part when it starts with ANSWER_TAG while streaming', () => {
+ const message: SBChatMessage = {
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ text: 'Some initial text'
+ },
+ {
+ type: 'text',
+ text: `${ANSWER_TAG}This is the answer to your question.`
+ }
+ ]
+ } as SBChatMessage;
+
+ const result = getAnswerPartFromAssistantMessage(message, true);
+
+ expect(result).toEqual({
+ type: 'text',
+ text: `${ANSWER_TAG}This is the answer to your question.`
+ });
+});
+
+test('getAnswerPartFromAssistantMessage returns last text part as fallback when not streaming and no ANSWER_TAG', () => {
+ const message: SBChatMessage = {
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ text: 'First text part'
+ },
+ {
+ type: 'tool-call',
+ id: 'call-1',
+ name: 'search',
+ args: {}
+ },
+ {
+ type: 'text',
+ text: 'This is the last text part without answer tag'
+ }
+ ]
+ } as SBChatMessage;
+
+ const result = getAnswerPartFromAssistantMessage(message, false);
+
+ expect(result).toEqual({
+ type: 'text',
+ text: 'This is the last text part without answer tag'
+ });
+});
+
+test('getAnswerPartFromAssistantMessage returns undefined when streaming and no ANSWER_TAG', () => {
+ const message: SBChatMessage = {
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ text: 'Some text without answer tag'
+ },
+ {
+ type: 'text',
+ text: 'Another text part'
+ }
+ ]
+ } as SBChatMessage;
+
+ const result = getAnswerPartFromAssistantMessage(message, true);
+
+ expect(result).toBeUndefined();
+});
+
+test('repairReferences fixes missing colon after @file', () => {
+ const input = 'See the function in @file{github.com/sourcebot-dev/sourcebot::auth.ts} for details.';
+ const expected = 'See the function in @file:{github.com/sourcebot-dev/sourcebot::auth.ts} for details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences fixes missing colon with range', () => {
+ const input = 'Check @file{github.com/sourcebot-dev/sourcebot::config.ts:15-20} for the configuration.';
+ const expected = 'Check @file:{github.com/sourcebot-dev/sourcebot::config.ts:15-20} for the configuration.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences fixes missing braces around filename', () => {
+ const input = 'The logic is in @file:github.com/sourcebot-dev/sourcebot::utils.js and handles validation.';
+ const expected = 'The logic is in @file:{github.com/sourcebot-dev/sourcebot::utils.js} and handles validation.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences fixes missing braces with path', () => {
+ const input = 'Look at @file:github.com/sourcebot-dev/sourcebot::src/components/Button.tsx for the component.';
+ const expected = 'Look at @file:{github.com/sourcebot-dev/sourcebot::src/components/Button.tsx} for the component.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences removes multiple ranges keeping only first', () => {
+ const input = 'See @file:{github.com/sourcebot-dev/sourcebot::service.ts:10-15,20-25,30-35} for implementation.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::service.ts:10-15} for implementation.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences fixes malformed triple number ranges', () => {
+ const input = 'Check @file:{github.com/sourcebot-dev/sourcebot::handler.ts:5-10-15} for the logic.';
+ const expected = 'Check @file:{github.com/sourcebot-dev/sourcebot::handler.ts:5-10} for the logic.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles multiple citations in same text', () => {
+ const input = 'See @file{github.com/sourcebot-dev/sourcebot::auth.ts} and @file:github.com/sourcebot-dev/sourcebot::config.js for setup details.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::auth.ts} and @file:{github.com/sourcebot-dev/sourcebot::config.js} for setup details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences leaves correctly formatted citations unchanged', () => {
+ const input = 'The function @file:{github.com/sourcebot-dev/sourcebot::utils.ts:42-50} handles validation correctly.';
+ expect(repairReferences(input)).toBe(input);
+});
+
+test('repairReferences handles edge cases with spaces and punctuation', () => {
+ const input = 'Functions like @file:github.com/sourcebot-dev/sourcebot::helper.ts, @file{github.com/sourcebot-dev/sourcebot::main.js}, and @file:{github.com/sourcebot-dev/sourcebot::app.ts:1-5,10-15} work.';
+ const expected = 'Functions like @file:{github.com/sourcebot-dev/sourcebot::helper.ts}, @file:{github.com/sourcebot-dev/sourcebot::main.js}, and @file:{github.com/sourcebot-dev/sourcebot::app.ts:1-5} work.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences returns empty string unchanged', () => {
+ expect(repairReferences('')).toBe('');
+});
+
+test('repairReferences returns text without citations unchanged', () => {
+ const input = 'This is just regular text without any file references.';
+ expect(repairReferences(input)).toBe(input);
+});
+
+test('repairReferences handles complex file paths correctly', () => {
+ const input = 'Check @file:github.com/sourcebot-dev/sourcebot::src/components/ui/Button/index.tsx for implementation.';
+ const expected = 'Check @file:{github.com/sourcebot-dev/sourcebot::src/components/ui/Button/index.tsx} for implementation.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles files with numbers and special characters', () => {
+ const input = 'See @file{github.com/sourcebot-dev/sourcebot::utils-v2.0.1.ts} and @file:github.com/sourcebot-dev/sourcebot::config_2024.json for setup.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::utils-v2.0.1.ts} and @file:{github.com/sourcebot-dev/sourcebot::config_2024.json} for setup.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles citation at end of sentence', () => {
+ const input = 'The implementation is in @file:github.com/sourcebot-dev/sourcebot::helper.ts.';
+ const expected = 'The implementation is in @file:{github.com/sourcebot-dev/sourcebot::helper.ts}.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences preserves already correct citations with ranges', () => {
+ const input = 'The function @file:{github.com/sourcebot-dev/sourcebot::utils.ts:10-20} and variable @file:{github.com/sourcebot-dev/sourcebot::config.js:5} work correctly.';
+ expect(repairReferences(input)).toBe(input);
+});
+
+test('repairReferences handles extra closing parenthesis', () => {
+ const input = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts:5-6)} for details.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts:5-6} for details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles extra colon at end of range', () => {
+ const input = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts:5-6:} for details.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts:5-6} for details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles inline code blocks around file references', () => {
+ const input = 'See `@file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts}` for details.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('repairReferences handles malformed inline code blocks', () => {
+ const input = 'See `@file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts`} for details.';
+ const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.';
+ expect(repairReferences(input)).toBe(expected);
+});
+
+test('buildSearchQuery returns base query when no filters provided', () => {
+ const result = buildSearchQuery({
+ query: 'console.log'
+ });
+
+ expect(result).toBe('console.log');
+});
+
+test('buildSearchQuery adds repoNamesFilter correctly', () => {
+ const result = buildSearchQuery({
+ query: 'function test',
+ repoNamesFilter: ['repo1', 'repo2']
+ });
+
+ expect(result).toBe('function test reposet:repo1,repo2');
+});
+
+test('buildSearchQuery adds single repoNamesFilter correctly', () => {
+ const result = buildSearchQuery({
+ query: 'function test',
+ repoNamesFilter: ['myrepo']
+ });
+
+ expect(result).toBe('function test reposet:myrepo');
+});
+
+test('buildSearchQuery ignores empty repoNamesFilter', () => {
+ const result = buildSearchQuery({
+ query: 'function test',
+ repoNamesFilter: []
+ });
+
+ expect(result).toBe('function test');
+});
+
+test('buildSearchQuery adds languageNamesFilter correctly', () => {
+ const result = buildSearchQuery({
+ query: 'class definition',
+ languageNamesFilter: ['typescript', 'javascript']
+ });
+
+ expect(result).toBe('class definition ( lang:typescript or lang:javascript )');
+});
+
+test('buildSearchQuery adds single languageNamesFilter correctly', () => {
+ const result = buildSearchQuery({
+ query: 'class definition',
+ languageNamesFilter: ['python']
+ });
+
+ expect(result).toBe('class definition ( lang:python )');
+});
+
+test('buildSearchQuery ignores empty languageNamesFilter', () => {
+ const result = buildSearchQuery({
+ query: 'class definition',
+ languageNamesFilter: []
+ });
+
+ expect(result).toBe('class definition');
+});
+
+test('buildSearchQuery adds fileNamesFilterRegexp correctly', () => {
+ const result = buildSearchQuery({
+ query: 'import statement',
+ fileNamesFilterRegexp: ['*.ts', '*.js']
+ });
+
+ expect(result).toBe('import statement ( file:*.ts or file:*.js )');
+});
+
+test('buildSearchQuery adds single fileNamesFilterRegexp correctly', () => {
+ const result = buildSearchQuery({
+ query: 'import statement',
+ fileNamesFilterRegexp: ['*.tsx']
+ });
+
+ expect(result).toBe('import statement ( file:*.tsx )');
+});
+
+test('buildSearchQuery ignores empty fileNamesFilterRegexp', () => {
+ const result = buildSearchQuery({
+ query: 'import statement',
+ fileNamesFilterRegexp: []
+ });
+
+ expect(result).toBe('import statement');
+});
+
+test('buildSearchQuery adds repoNamesFilterRegexp correctly', () => {
+ const result = buildSearchQuery({
+ query: 'bug fix',
+ repoNamesFilterRegexp: ['org/repo1', 'org/repo2']
+ });
+
+ expect(result).toBe('bug fix ( repo:org/repo1 or repo:org/repo2 )');
+});
+
+test('buildSearchQuery adds single repoNamesFilterRegexp correctly', () => {
+ const result = buildSearchQuery({
+ query: 'bug fix',
+ repoNamesFilterRegexp: ['myorg/myrepo']
+ });
+
+ expect(result).toBe('bug fix ( repo:myorg/myrepo )');
+});
+
+test('buildSearchQuery ignores empty repoNamesFilterRegexp', () => {
+ const result = buildSearchQuery({
+ query: 'bug fix',
+ repoNamesFilterRegexp: []
+ });
+
+ expect(result).toBe('bug fix');
+});
+
+test('buildSearchQuery combines multiple filters correctly', () => {
+ const result = buildSearchQuery({
+ query: 'authentication',
+ repoNamesFilter: ['backend', 'frontend'],
+ languageNamesFilter: ['typescript', 'javascript'],
+ fileNamesFilterRegexp: ['*.ts', '*.js'],
+ repoNamesFilterRegexp: ['org/auth-*']
+ });
+
+ expect(result).toBe(
+ 'authentication reposet:backend,frontend ( lang:typescript or lang:javascript ) ( file:*.ts or file:*.js ) ( repo:org/auth-* )'
+ );
+});
+
+test('buildSearchQuery handles mixed empty and non-empty filters', () => {
+ const result = buildSearchQuery({
+ query: 'error handling',
+ repoNamesFilter: [],
+ languageNamesFilter: ['python'],
+ fileNamesFilterRegexp: [],
+ repoNamesFilterRegexp: ['error/*']
+ });
+
+ expect(result).toBe('error handling ( lang:python ) ( repo:error/* )');
+});
+
+test('buildSearchQuery handles empty base query', () => {
+ const result = buildSearchQuery({
+ query: '',
+ repoNamesFilter: ['repo1'],
+ languageNamesFilter: ['typescript']
+ });
+
+ expect(result).toBe(' reposet:repo1 ( lang:typescript )');
+});
+
+test('buildSearchQuery handles query with special characters', () => {
+ const result = buildSearchQuery({
+ query: 'console.log("hello world")',
+ repoNamesFilter: ['test-repo']
+ });
+
+ expect(result).toBe('console.log("hello world") reposet:test-repo');
+});
\ No newline at end of file
diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts
new file mode 100644
index 00000000..c57f3fd5
--- /dev/null
+++ b/packages/web/src/features/chat/utils.ts
@@ -0,0 +1,368 @@
+import { CreateUIMessage, TextUIPart, UIMessagePart } from "ai";
+import { Descendant, Editor, Point, Range, Transforms } from "slate";
+import { ANSWER_TAG, FILE_REFERENCE_PREFIX, FILE_REFERENCE_REGEX } from "./constants";
+import {
+ CustomEditor,
+ CustomText,
+ FileReference,
+ FileSource,
+ MentionData,
+ MentionElement,
+ ParagraphElement,
+ SBChatMessage,
+ SBChatMessagePart,
+ SBChatMessageToolTypes,
+ SearchScope,
+ Source,
+} from "./types";
+
+export const insertMention = (editor: CustomEditor, data: MentionData, target?: Range | null) => {
+ const mention: MentionElement = {
+ type: 'mention',
+ data,
+ children: [{ text: '' }],
+ }
+
+ if (target) {
+ Transforms.select(editor, target)
+ }
+
+ Transforms.insertNodes(editor, mention)
+ Transforms.move(editor)
+}
+
+// @see: https://github.com/ianstormtaylor/slate/issues/4162#issuecomment-1127062098
+export function word(
+ editor: CustomEditor,
+ location: Range,
+ options: {
+ terminator?: string[]
+ include?: boolean
+ directions?: 'both' | 'left' | 'right'
+ } = {},
+): Range | undefined {
+ const { terminator = [' '], include = false, directions = 'both' } = options
+
+ const { selection } = editor
+ if (!selection) return
+
+ // Get start and end, modify it as we move along.
+ let [start, end] = Range.edges(location)
+
+ let point: Point = start
+
+ function move(direction: 'right' | 'left'): boolean {
+ const next =
+ direction === 'right'
+ ? Editor.after(editor, point, {
+ unit: 'character',
+ })
+ : Editor.before(editor, point, { unit: 'character' })
+
+ const wordNext =
+ next &&
+ Editor.string(
+ editor,
+ direction === 'right' ? { anchor: point, focus: next } : { anchor: next, focus: point },
+ )
+
+ const last = wordNext && wordNext[direction === 'right' ? 0 : wordNext.length - 1]
+ if (next && last && !terminator.includes(last)) {
+ point = next
+
+ if (point.offset === 0) {
+ // Means we've wrapped to beginning of another block
+ return false
+ }
+ } else {
+ return false
+ }
+
+ return true
+ }
+
+ // Move point and update start & end ranges
+
+ // Move forwards
+ if (directions !== 'left') {
+ point = end
+ while (move('right'));
+ end = point
+ }
+
+ // Move backwards
+ if (directions !== 'right') {
+ point = start
+ while (move('left'));
+ start = point
+ }
+
+ if (include) {
+ return {
+ anchor: Editor.before(editor, start, { unit: 'offset' }) ?? start,
+ focus: Editor.after(editor, end, { unit: 'offset' }) ?? end,
+ }
+ }
+
+ return { anchor: start, focus: end }
+}
+
+export const isMentionElement = (element: Descendant): element is MentionElement => {
+ return 'type' in element && element.type === 'mention';
+}
+
+export const isCustomTextElement = (element: Descendant): element is CustomText => {
+ return 'text' in element && typeof element.text === 'string';
+}
+
+export const isParagraphElement = (element: Descendant): element is ParagraphElement => {
+ return 'type' in element && element.type === 'paragraph';
+}
+
+export const slateContentToString = (children: Descendant[]): string => {
+ return children.map((child) => {
+ if (isCustomTextElement(child)) {
+ return child.text;
+ }
+
+ else if (isMentionElement(child)) {
+ const { type } = child.data;
+
+ switch (type) {
+ case 'file':
+ return `${fileReferenceToString({ repo: child.data.repo, path: child.data.path })} `;
+ }
+ }
+
+ else if (isParagraphElement(child)) {
+ return `${slateContentToString(child.children)}\n`;
+ }
+
+ else {
+ return "";
+ }
+ }).join("");
+}
+
+export const getAllMentionElements = (children: Descendant[]): MentionElement[] => {
+ return children.flatMap((child) => {
+ if (isCustomTextElement(child)) {
+ return [];
+ }
+
+ if (isMentionElement(child)) {
+ return [child];
+ }
+
+ return getAllMentionElements(child.children);
+ });
+}
+
+// @see: https://stackoverflow.com/a/74102147
+export const resetEditor = (editor: CustomEditor) => {
+ const point = { path: [0, 0], offset: 0 }
+ editor.selection = { anchor: point, focus: point };
+ editor.history = { redos: [], undos: [] };
+ editor.children = [{
+ type: "paragraph",
+ children: [{ text: "" }]
+ }];
+}
+
+export const addLineNumbers = (source: string, lineOffset = 1) => {
+ return source.split('\n').map((line, index) => `${index + lineOffset}:${line}`).join('\n');
+}
+
+export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[]): CreateUIMessage => {
+ // Converts applicable mentions into sources.
+ const sources: Source[] = mentions
+ .map((mention) => {
+ if (mention.type === 'file') {
+ const fileSource: FileSource = {
+ type: 'file',
+ path: mention.path,
+ repo: mention.repo,
+ name: mention.name,
+ language: mention.language,
+ revision: mention.revision,
+ }
+ return fileSource;
+ }
+
+ return undefined;
+ })
+ .filter((source) => source !== undefined);
+
+ return {
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text,
+ },
+ ...sources.map((data) => ({
+ type: 'data-source',
+ data,
+ })) as UIMessagePart<{ source: Source }, SBChatMessageToolTypes>[],
+ ],
+ metadata: {
+ selectedSearchScopes,
+ },
+ }
+}
+
+export const getFileReferenceId = ({ repo, path, range }: Omit) => {
+ return `file-reference-${repo}::${path}${range ? `-${range.startLine}-${range.endLine}` : ''}`;
+}
+
+export const fileReferenceToString = ({ repo, path, range }: Omit) => {
+ return `${FILE_REFERENCE_PREFIX}{${repo}::${path}${range ? `:${range.startLine}-${range.endLine}` : ''}}`;
+}
+
+export const createFileReference = ({ repo, path, startLine, endLine }: { repo: string, path: string, startLine?: string, endLine?: string }): FileReference => {
+ const range = startLine && endLine ? {
+ startLine: parseInt(startLine),
+ endLine: parseInt(endLine),
+ } : startLine ? {
+ startLine: parseInt(startLine),
+ endLine: parseInt(startLine),
+ } : undefined;
+
+ return {
+ type: 'file',
+ id: getFileReferenceId({ repo, path, range }),
+ repo,
+ path,
+ range,
+ }
+}
+
+/**
+ * Converts LLM text that includes references (e.g., @file:...) into a portable
+ * Markdown format. Practically, this means converting references into Markdown
+ * links and removing the answer tag.
+ */
+export const convertLLMOutputToPortableMarkdown = (text: string): string => {
+ return text
+ .replace(ANSWER_TAG, '')
+ .replace(FILE_REFERENCE_REGEX, (_, _repo, fileName, startLine, endLine) => {
+ const displayName = fileName.split('/').pop() || fileName;
+
+ let linkText = displayName;
+ if (startLine) {
+ if (endLine && startLine !== endLine) {
+ linkText += `:${startLine}-${endLine}`;
+ } else {
+ linkText += `:${startLine}`;
+ }
+ }
+
+ return `[${linkText}](${fileName})`;
+ })
+ .trim();
+}
+
+// Groups message parts into groups based on step-start delimiters.
+export const groupMessageIntoSteps = (parts: SBChatMessagePart[]) => {
+ if (!parts || parts.length === 0) {
+ return [];
+ }
+
+ const steps: SBChatMessagePart[][] = [];
+ let currentStep: SBChatMessagePart[] = [];
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+
+ if (part.type === 'step-start') {
+ if (currentStep.length > 0) {
+ steps.push([...currentStep]);
+ }
+ currentStep = [part];
+ } else {
+ currentStep.push(part);
+ }
+ }
+
+ if (currentStep.length > 0) {
+ steps.push(currentStep);
+ }
+
+ return steps;
+}
+
+// LLMs like to not follow instructions... this takes care of some common mistakes they tend to make.
+export const repairReferences = (text: string): string => {
+ return text
+ // Fix missing colon: @file{...} -> @file:{...}
+ .replace(/@file\{([^}]+)\}/g, '@file:{$1}')
+ // Fix missing braces: @file:filename -> @file:{filename}
+ .replace(/@file:([^\s{]\S*?)(\s|[,;!?](?:\s|$)|\.(?:\s|$)|$)/g, '@file:{$1}$2')
+ // Fix multiple ranges: keep only first range
+ .replace(/@file:\{(.+?):(\d+-\d+),[\d,-]+\}/g, '@file:{$1:$2}')
+ // Fix malformed ranges
+ .replace(/@file:\{(.+?):(\d+)-(\d+)-(\d+)\}/g, '@file:{$1:$2-$3}')
+ // Fix extra closing parenthesis: @file:{...)} -> @file:{...}
+ .replace(/@file:\{([^}]+)\)\}/g, '@file:{$1}')
+ // Fix extra colon at end: @file:{...range:} -> @file:{...range}
+ .replace(/@file:\{(.+?):(\d+(?:-\d+)?):?\}/g, '@file:{$1:$2}')
+ // Fix inline code blocks around file references: `@file:{...}` -> @file:{...}
+ .replace(/`(@file:\{[^}]+\})`/g, '$1')
+ // Fix malformed inline code blocks: `@file:{...`} -> @file:{...}
+ .replace(/`(@file:\{[^`]+)`\}/g, '$1}');
+};
+
+// Attempts to find the part of the assistant's message
+// that contains the answer.
+export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStreaming: boolean): TextUIPart | undefined => {
+ const lastTextPart = message.parts
+ .findLast((part) => part.type === 'text')
+
+ if (lastTextPart?.text.startsWith(ANSWER_TAG)) {
+ return lastTextPart;
+ }
+
+ // If the agent did not include the answer tag, then fallback to using the last text part.
+ // Only do this when we are no longer streaming since the agent may still be thinking.
+ if (!isStreaming && lastTextPart) {
+ return lastTextPart;
+ }
+
+ return undefined;
+}
+
+export const buildSearchQuery = (options: {
+ query: string,
+ repoNamesFilter?: string[],
+ repoNamesFilterRegexp?: string[],
+ languageNamesFilter?: string[],
+ fileNamesFilterRegexp?: string[],
+}) => {
+ const {
+ query: _query,
+ repoNamesFilter,
+ repoNamesFilterRegexp,
+ languageNamesFilter,
+ fileNamesFilterRegexp,
+ } = options;
+
+ let query = `${_query}`;
+
+ if (repoNamesFilter && repoNamesFilter.length > 0) {
+ query += ` reposet:${repoNamesFilter.join(',')}`;
+ }
+
+ if (languageNamesFilter && languageNamesFilter.length > 0) {
+ query += ` ( lang:${languageNamesFilter.join(' or lang:')} )`;
+ }
+
+ if (fileNamesFilterRegexp && fileNamesFilterRegexp.length > 0) {
+ query += ` ( file:${fileNamesFilterRegexp.join(' or file:')} )`;
+ }
+
+ if (repoNamesFilterRegexp && repoNamesFilterRegexp.length > 0) {
+ query += ` ( repo:${repoNamesFilterRegexp.join(' or repo:')} )`;
+ }
+
+ return query;
+}
\ No newline at end of file
diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts
index f342de6e..b55cfa30 100644
--- a/packages/web/src/features/codeNav/actions.ts
+++ b/packages/web/src/features/codeNav/actions.ts
@@ -34,7 +34,7 @@ export const findSearchBasedSymbolReferences = async (
query,
matches: MAX_REFERENCE_COUNT,
contextLines: 0,
- }, domain);
+ });
if (isServiceError(searchResult)) {
return searchResult;
@@ -67,7 +67,7 @@ export const findSearchBasedSymbolDefinitions = async (
query,
matches: MAX_REFERENCE_COUNT,
contextLines: 0,
- }, domain);
+ });
if (isServiceError(searchResult)) {
return searchResult;
diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts
index 001a6d10..003b82b2 100644
--- a/packages/web/src/features/fileTree/actions.ts
+++ b/packages/web/src/features/fileTree/actions.ts
@@ -1,13 +1,13 @@
'use server';
-import { sew, withAuth, withOrgMembership } from '@/actions';
+import { sew } from '@/actions';
import { env } from '@/env.mjs';
-import { OrgRole, Repo } from '@sourcebot/db';
-import { prisma } from '@/prisma';
import { notFound, unexpectedError } from '@/lib/serviceError';
-import { simpleGit } from 'simple-git';
-import path from 'path';
+import { withOptionalAuthV2 } from '@/withAuthV2';
+import { Repo } from '@sourcebot/db';
import { createLogger } from '@sourcebot/logger';
+import path from 'path';
+import { simpleGit } from 'simple-git';
const logger = createLogger('file-tree');
@@ -25,188 +25,182 @@ export type FileTreeNode = FileTreeItem & {
* Returns the tree of files (blobs) and directories (trees) for a given repository,
* at a given revision.
*/
-export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
- withAuth((session) =>
- withOrgMembership(session, domain, async ({ org }) => {
- const { repoName, revisionName } = params;
- const repo = await prisma.repo.findFirst({
- where: {
- name: repoName,
- orgId: org.id,
- },
- });
+export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ const { repoName, revisionName } = params;
+ const repo = await prisma.repo.findFirst({
+ where: {
+ name: repoName,
+ orgId: org.id,
+ },
+ });
- if (!repo) {
- return notFound();
- }
+ if (!repo) {
+ return notFound();
+ }
- const { path: repoPath } = getRepoPath(repo);
+ const { path: repoPath } = getRepoPath(repo);
- const git = simpleGit().cwd(repoPath);
+ const git = simpleGit().cwd(repoPath);
- let result: string;
- try {
- result = await git.raw([
- 'ls-tree',
- revisionName,
- // recursive
- '-r',
- // include trees when recursing
- '-t',
- // format as output as {type},{path}
- '--format=%(objecttype),%(path)',
- ]);
- } catch (error) {
- logger.error('git ls-tree failed.', { error });
- return unexpectedError('git ls-tree command failed.');
- }
+ let result: string;
+ try {
+ result = await git.raw([
+ 'ls-tree',
+ revisionName,
+ // recursive
+ '-r',
+ // include trees when recursing
+ '-t',
+ // format as output as {type},{path}
+ '--format=%(objecttype),%(path)',
+ ]);
+ } catch (error) {
+ logger.error('git ls-tree failed.', { error });
+ return unexpectedError('git ls-tree command failed.');
+ }
- const lines = result.split('\n').filter(line => line.trim());
-
- const flatList = lines.map(line => {
- const [type, path] = line.split(',');
- return {
- type,
- path,
- }
- });
-
- const tree = buildFileTree(flatList);
+ const lines = result.split('\n').filter(line => line.trim());
+ const flatList = lines.map(line => {
+ const [type, path] = line.split(',');
return {
- tree,
+ type,
+ path,
}
+ });
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
-);
+ const tree = buildFileTree(flatList);
+
+ return {
+ tree,
+ }
+
+ }));
/**
* Returns the contents of a folder at a given path in a given repository,
* at a given revision.
*/
-export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() =>
- withAuth((session) =>
- withOrgMembership(session, domain, async ({ org }) => {
- const { repoName, revisionName, path } = params;
- const repo = await prisma.repo.findFirst({
- where: {
- name: repoName,
- orgId: org.id,
- },
- });
+export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }) => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ const { repoName, revisionName, path } = params;
+ const repo = await prisma.repo.findFirst({
+ where: {
+ name: repoName,
+ orgId: org.id,
+ },
+ });
- if (!repo) {
- return notFound();
+ if (!repo) {
+ return notFound();
+ }
+
+ const { path: repoPath } = getRepoPath(repo);
+
+ // @note: we don't allow directory traversal
+ // or null bytes in the path.
+ if (path.includes('..') || path.includes('\0')) {
+ return notFound();
+ }
+
+ // Normalize the path by...
+ let normalizedPath = path;
+
+ // ... adding a trailing slash if it doesn't have one.
+ // This is important since ls-tree won't return the contents
+ // of a directory if it doesn't have a trailing slash.
+ if (!normalizedPath.endsWith('/')) {
+ normalizedPath = `${normalizedPath}/`;
+ }
+
+ // ... removing any leading slashes. This is needed since
+ // the path is relative to the repository's root, so we
+ // need a relative path.
+ if (normalizedPath.startsWith('/')) {
+ normalizedPath = normalizedPath.slice(1);
+ }
+
+ const git = simpleGit().cwd(repoPath);
+
+ let result: string;
+ try {
+ result = await git.raw([
+ 'ls-tree',
+ revisionName,
+ // format as output as {type},{path}
+ '--format=%(objecttype),%(path)',
+ ...(normalizedPath.length === 0 ? [] : [normalizedPath]),
+ ]);
+ } catch (error) {
+ logger.error('git ls-tree failed.', { error });
+ return unexpectedError('git ls-tree command failed.');
+ }
+
+ const lines = result.split('\n').filter(line => line.trim());
+
+ const contents: FileTreeItem[] = lines.map(line => {
+ const [type, path] = line.split(',');
+ const name = path.split('/').pop() ?? '';
+
+ return {
+ type,
+ path,
+ name,
}
+ });
- const { path: repoPath } = getRepoPath(repo);
+ return contents;
+ }));
- // @note: we don't allow directory traversal
- // or null bytes in the path.
- if (path.includes('..') || path.includes('\0')) {
- return notFound();
+export const getFiles = async (params: { repoName: string, revisionName: string }) => sew(() =>
+ withOptionalAuthV2(async ({ org, prisma }) => {
+ const { repoName, revisionName } = params;
+
+ const repo = await prisma.repo.findFirst({
+ where: {
+ name: repoName,
+ orgId: org.id,
+ },
+ });
+
+ if (!repo) {
+ return notFound();
+ }
+
+ const { path: repoPath } = getRepoPath(repo);
+
+ const git = simpleGit().cwd(repoPath);
+
+ let result: string;
+ try {
+ result = await git.raw([
+ 'ls-tree',
+ revisionName,
+ // recursive
+ '-r',
+ // only return the names of the files
+ '--name-only',
+ ]);
+ } catch (error) {
+ logger.error('git ls-tree failed.', { error });
+ return unexpectedError('git ls-tree command failed.');
+ }
+
+ const paths = result.split('\n').filter(line => line.trim());
+
+ const files: FileTreeItem[] = paths.map(path => {
+ const name = path.split('/').pop() ?? '';
+ return {
+ type: 'blob',
+ path,
+ name,
}
+ });
- // Normalize the path by...
- let normalizedPath = path;
+ return files;
- // ... adding a trailing slash if it doesn't have one.
- // This is important since ls-tree won't return the contents
- // of a directory if it doesn't have a trailing slash.
- if (!normalizedPath.endsWith('/')) {
- normalizedPath = `${normalizedPath}/`;
- }
-
- // ... removing any leading slashes. This is needed since
- // the path is relative to the repository's root, so we
- // need a relative path.
- if (normalizedPath.startsWith('/')) {
- normalizedPath = normalizedPath.slice(1);
- }
-
- const git = simpleGit().cwd(repoPath);
-
- let result: string;
- try {
- result = await git.raw([
- 'ls-tree',
- revisionName,
- // format as output as {type},{path}
- '--format=%(objecttype),%(path)',
- ...(normalizedPath.length === 0 ? [] : [normalizedPath]),
- ]);
- } catch (error) {
- logger.error('git ls-tree failed.', { error });
- return unexpectedError('git ls-tree command failed.');
- }
-
- const lines = result.split('\n').filter(line => line.trim());
-
- const contents: FileTreeItem[] = lines.map(line => {
- const [type, path] = line.split(',');
- const name = path.split('/').pop() ?? '';
-
- return {
- type,
- path,
- name,
- }
- });
-
- return contents;
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
-);
-
-export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
- withAuth((session) =>
- withOrgMembership(session, domain, async ({ org }) => {
- const { repoName, revisionName } = params;
-
- const repo = await prisma.repo.findFirst({
- where: {
- name: repoName,
- orgId: org.id,
- },
- });
-
- if (!repo) {
- return notFound();
- }
-
- const { path: repoPath } = getRepoPath(repo);
-
- const git = simpleGit().cwd(repoPath);
-
- let result: string;
- try {
- result = await git.raw([
- 'ls-tree',
- revisionName,
- // recursive
- '-r',
- // only return the names of the files
- '--name-only',
- ]);
- } catch (error) {
- logger.error('git ls-tree failed.', { error });
- return unexpectedError('git ls-tree command failed.');
- }
-
- const paths = result.split('\n').filter(line => line.trim());
-
- const files: FileTreeItem[] = paths.map(path => {
- const name = path.split('/').pop() ?? '';
- return {
- type: 'blob',
- path,
- name,
- }
- });
-
- return files;
-
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
-);
+ }));
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
const root: FileTreeNode = {
diff --git a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx
index 8fec32bf..17fc1ed3 100644
--- a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx
+++ b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx
@@ -6,6 +6,7 @@ import clsx from "clsx";
import scrollIntoView from 'scroll-into-view-if-needed';
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import { FileTreeItemIcon } from "./fileTreeItemIcon";
+import Link from "next/link";
export const FileTreeItemComponent = ({
node,
@@ -13,7 +14,9 @@ export const FileTreeItemComponent = ({
depth,
isCollapsed = false,
isCollapseChevronVisible = true,
+ href,
onClick,
+ onNavigate,
parentRef,
}: {
node: FileTreeItem,
@@ -21,10 +24,12 @@ export const FileTreeItemComponent = ({
depth: number,
isCollapsed?: boolean,
isCollapseChevronVisible?: boolean,
- onClick: () => void,
- parentRef: React.RefObject,
+ href: string,
+ onClick?: (e: React.MouseEvent) => void,
+ onNavigate?: (e: { preventDefault: () => void }) => void,
+ parentRef: React.RefObject,
}) => {
- const ref = useRef(null);
+ const ref = useRef(null);
useEffect(() => {
if (isActive && ref.current) {
@@ -51,20 +56,16 @@ export const FileTreeItemComponent = ({
}, [isActive, parentRef]);
return (
- {
- if (e.key === 'Enter') {
- e.preventDefault();
- onClick();
- }
- }}
onClick={onClick}
+ onNavigate={onNavigate}
>
{node.name}
-
+
)
}
diff --git a/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx b/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx
index 1d481e3d..e685899a 100644
--- a/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx
+++ b/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx
@@ -2,9 +2,8 @@
import { FileTreeItem } from "../actions";
import { useMemo } from "react";
-import { getIconForFile, getIconForFolder } from "vscode-icons-js";
-import { Icon } from '@iconify/react';
-import { cn } from "@/lib/utils";
+import { VscodeFolderIcon } from "@/app/components/vscodeFolderIcon";
+import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
interface FileTreeItemIconProps {
item: FileTreeItem;
@@ -12,23 +11,13 @@ interface FileTreeItemIconProps {
}
export const FileTreeItemIcon = ({ item, className }: FileTreeItemIconProps) => {
- const iconName = useMemo(() => {
+ const ItemIcon = useMemo(() => {
if (item.type === 'tree') {
- const icon = getIconForFolder(item.name);
- if (icon) {
- const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
- return iconName;
- }
- } else if (item.type === 'blob') {
- const icon = getIconForFile(item.name);
- if (icon) {
- const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
- return iconName;
- }
+ return
+ } else {
+ return
}
+ }, [item.name, item.type, className]);
- return "vscode-icons:file-type-unknown";
- }, [item.name, item.type]);
-
- return
;
+ return ItemIcon;
}
\ No newline at end of file
diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx
index fffabeba..a7579628 100644
--- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx
+++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx
@@ -3,7 +3,6 @@
import { getTree } from "../actions";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
-import { useDomain } from "@/hooks/useDomain";
import { ResizablePanel } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
@@ -41,17 +40,16 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
updateBrowseState,
} = useBrowseState();
- const domain = useDomain();
const { repoName, revisionName, path } = useBrowseParams();
const fileTreePanelRef = useRef
(null);
const { data, isPending, isError } = useQuery({
- queryKey: ['tree', repoName, revisionName, domain],
+ queryKey: ['tree', repoName, revisionName],
queryFn: () => unwrapServiceError(
getTree({
repoName,
revisionName: revisionName ?? 'HEAD',
- }, domain)
+ })
),
});
diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx
index bc7347fb..88c1ab2f 100644
--- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx
+++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx
@@ -4,9 +4,9 @@ import { FileTreeNode as RawFileTreeNode } from "../actions";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import React, { useCallback, useMemo, useState, useEffect, useRef } from "react";
import { FileTreeItemComponent } from "./fileTreeItemComponent";
-import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
+import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
-
+import { useDomain } from "@/hooks/useDomain";
export type FileTreeNode = Omit & {
isCollapsed: boolean;
@@ -41,8 +41,8 @@ interface PureFileTreePanelProps {
export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => {
const [tree, setTree] = useState(buildCollapsibleTree(_tree));
const scrollAreaRef = useRef(null);
- const { navigateToPath } = useBrowseNavigation();
const { repoName, revisionName } = useBrowseParams();
+ const domain = useDomain();
// @note: When `_tree` changes, it indicates that a new tree has been loaded.
// In that case, we need to rebuild the collapsible tree.
@@ -72,21 +72,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
}
}, [path, setIsCollapsed]);
- const onNodeClicked = useCallback((node: FileTreeNode) => {
- if (node.type === 'tree') {
- setIsCollapsed(node.path, !node.isCollapsed);
- }
- else if (node.type === 'blob') {
- navigateToPath({
- repoName: repoName,
- revisionName: revisionName,
- path: node.path,
- pathType: 'blob',
- });
-
- }
- }, [setIsCollapsed, navigateToPath, repoName, revisionName]);
-
const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => {
return (
<>
@@ -94,13 +79,35 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
return (
onNodeClicked(node)}
+ // Only collapse the tree when a regular click happens.
+ // (i.e., not ctrl/cmd click).
+ onClick={(e) => {
+ const isMetaOrCtrlKey = e.metaKey || e.ctrlKey;
+ if (node.type === 'tree' && !isMetaOrCtrlKey) {
+ setIsCollapsed(node.path, !node.isCollapsed);
+ }
+ }}
+ // @note: onNavigate _won't_ be called when the user ctrl/cmd clicks on a tree node.
+ // So when a regular click happens, we want to prevent the navigation from happening
+ // and instead collapse the tree.
+ onNavigate={(e) => {
+ if (node.type === 'tree') {
+ e.preventDefault();
+ }
+ }}
parentRef={scrollAreaRef}
/>
{node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)}
@@ -109,7 +116,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
})}
>
);
- }, [path, onNodeClicked]);
+ }, [path]);
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);
diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts
index e1fd756b..249abb42 100644
--- a/packages/web/src/features/search/fileSourceApi.ts
+++ b/packages/web/src/features/search/fileSourceApi.ts
@@ -1,52 +1,62 @@
'use server';
import escapeStringRegexp from "escape-string-regexp";
-import { fileNotFound, ServiceError } from "../../lib/serviceError";
+import { fileNotFound, ServiceError, unexpectedError } from "../../lib/serviceError";
import { FileSourceRequest, FileSourceResponse } from "./types";
import { isServiceError } from "../../lib/utils";
import { search } from "./searchApi";
-import { sew, withAuth, withOrgMembership } from "@/actions";
-import { OrgRole } from "@sourcebot/db";
+import { sew } from "@/actions";
+import { withOptionalAuthV2 } from "@/withAuthV2";
// @todo (bkellam) : We should really be using `git show :` to fetch file contents here.
// This will allow us to support permalinks to files at a specific revision that may not be indexed
// by zoekt.
-export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise => sew(() =>
- withAuth((userId, _apiKeyHash) =>
- withOrgMembership(userId, domain, async () => {
- const escapedFileName = escapeStringRegexp(fileName);
- const escapedRepository = escapeStringRegexp(repository);
+export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): Promise => sew(() =>
+ withOptionalAuthV2(async () => {
+ const escapedFileName = escapeStringRegexp(fileName);
+ const escapedRepository = escapeStringRegexp(repository);
- let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
- if (branch) {
- query = query.concat(` branch:${branch}`);
- }
+ let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
+ if (branch) {
+ query = query.concat(` branch:${branch}`);
+ }
- const searchResponse = await search({
- query,
- matches: 1,
- whole: true,
- }, domain, apiKey);
+ const searchResponse = await search({
+ query,
+ matches: 1,
+ whole: true,
+ });
- if (isServiceError(searchResponse)) {
- return searchResponse;
- }
+ if (isServiceError(searchResponse)) {
+ return searchResponse;
+ }
- const files = searchResponse.files;
+ const files = searchResponse.files;
- if (!files || files.length === 0) {
- return fileNotFound(fileName, repository);
- }
+ if (!files || files.length === 0) {
+ return fileNotFound(fileName, repository);
+ }
- const file = files[0];
- const source = file.content ?? '';
- const language = file.language;
-
- return {
- source,
- language,
- webUrl: file.webUrl,
- } satisfies FileSourceResponse;
+ const file = files[0];
+ const source = file.content ?? '';
+ const language = file.language;
- }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
-);
+ const repoInfo = searchResponse.repositoryInfo.find((repo) => repo.id === file.repositoryId);
+ if (!repoInfo) {
+ // This should never happen.
+ return unexpectedError("Repository info not found");
+ }
+
+ return {
+ source,
+ language,
+ path: fileName,
+ repository,
+ repositoryCodeHostType: repoInfo.codeHostType,
+ repositoryDisplayName: repoInfo.displayName,
+ repositoryWebUrl: repoInfo.webUrl,
+ branch,
+ webUrl: file.webUrl,
+ } satisfies FileSourceResponse;
+
+ }));
diff --git a/packages/web/src/features/search/listReposApi.ts b/packages/web/src/features/search/listReposApi.ts
deleted file mode 100644
index 90a685bd..00000000
--- a/packages/web/src/features/search/listReposApi.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { OrgRole } from "@sourcebot/db";
-import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
-import { ListRepositoriesResponse } from "./types";
-import { zoektFetch } from "./zoektClient";
-import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
-import { sew, withAuth, withOrgMembership } from "@/actions";
-
-export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise => sew(() =>
- withAuth((userId, _apiKeyHash) =>
- withOrgMembership(userId, domain, async ({ org }) => {
- const body = JSON.stringify({
- opts: {
- Field: 0,
- }
- });
-
- let header: Record