feat: Ask Sourcebot (#392)

Co-authored-by: msukkari <michael.sukkarieh@mail.mcgill.ca>
This commit is contained in:
Brendan Kellam 2025-07-23 11:25:15 -07:00 committed by GitHub
parent eb20027210
commit 2b0dac4782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 16281 additions and 815 deletions

View file

@ -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 }}

View file

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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)
## [4.5.3] - 2025-07-20
### Changed

View file

@ -44,16 +44,20 @@
# About
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.
Sourcebot is a self-hosted tool that helps you understand your codebase.
https://github.com/user-attachments/assets/ced355f3-967e-4f37-ae6e-74ab8c06b9ec
- **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 theyre hosted.
https://github.com/user-attachments/assets/286ad97a-a543-4eef-a2f1-4fa31bea1b32
## 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
- 🤖 **Bring your own model**: Connect Sourcebot to any of the reasoning models you're already using.
- 🔍 **Multi-repo support**: 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.
You can try out our public hosted demo [here](https://demo.sourcebot.dev)!

View file

@ -28,13 +28,21 @@
"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",
@ -51,6 +59,7 @@
{
"group": "Configuration",
"pages": [
"docs/configuration/config-file",
{
"group": "Indexing your code",
"pages": [
@ -66,8 +75,7 @@
"docs/connections/request-new"
]
},
"docs/license-key",
"docs/configuration/environment-variables",
"docs/configuration/language-model-providers",
{
"group": "Authentication",
"pages": [
@ -78,6 +86,8 @@
"docs/configuration/auth/faq"
]
},
"docs/configuration/environment-variables",
"docs/license-key",
"docs/configuration/transactional-emails",
"docs/configuration/structured-logging",
"docs/configuration/audit-logs"

View file

@ -0,0 +1,49 @@
---
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 | 2MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
| `maxTrigramCount` | number | 20000 | 1 | Maximum trigrams per document. Larger files are skipped. |
| `reindexIntervalMs` | number | 1hour | 1 | Interval at which all repositories are reindexed. |
| `resyncConnectionIntervalMs` | number | 24hours | 1 | Interval for checking connections that need resyncing. |
| `resyncConnectionPollingIntervalMs` | number | 1second | 1 | DB polling rate for connections that need resyncing. |
| `reindexRepoPollingIntervalMs` | number | 1second | 1 | DB polling rate for repos that should be reindexed. |
| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connectionsync jobs. |
| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repoindexing jobs. |
| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repogarbagecollection jobs. |
| `repoGarbageCollectionGracePeriodMs` | number | 10seconds | 1 | Grace period to avoid deleting shards while loading. |
| `repoIndexTimeoutMs` | number | 2hours | 1 | Timeout for a single repoindexing run. |
| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |

View file

@ -0,0 +1,184 @@
---
title: Language Model Providers
sidebarTitle: Language model providers
---
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/discussions/categories/feature-requests).
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).
<Note>Any parameter defined using `env` will read the value from the corresponding environment variable you provide Sourcebot</Note>
### 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"
},
"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"
}
]
}
```
### 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
<Note>If you're using an Anthropic model on Google Vertex, you must define a [Google Vertex Anthropic](#google-vertex-anthropic) provider instead</Note>
<Note>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.</Note>
[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", // e.g., "gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash"
"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, e.g., "us-central1", "us-east1", "europe-west1"
"credentials": {
"env": "GOOGLE_APPLICATION_CREDENTIALS"
},
"baseUrl": "OPTIONAL_BASE_URL"
}
]
}
```
### Google Vertex Anthropic
<Note>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.</Note>
[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", // e.g., "claude-sonnet-4"
"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, e.g., "us-central1", "us-east1", "europe-west1"
"credentials": {
"env": "GOOGLE_APPLICATION_CREDENTIALS"
},
"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", // e.g., "gpt-4.1", "o4-mini", "o3", "o3-deep-research"
"displayName": "OPTIONAL_DISPLAY_NAME",
"token": {
"env": "OPENAI_API_KEY"
},
"baseUrl": "OPTIONAL_BASE_URL"
}
]
}
```

View file

@ -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).
</Note>
If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
## Examples
<AccordionGroup>

View file

@ -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).
</Note>
If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first.
## Examples
<AccordionGroup>

View file

@ -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:

View file

@ -10,6 +10,8 @@ import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx'
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:

View file

@ -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
<AccordionGroup>

View file

@ -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
<AccordionGroup>

View file

@ -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

View file

@ -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
<Warning>

View file

@ -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.

View file

@ -72,7 +72,7 @@ The following guide will walk you through the steps to deploy Sourcebot on your
</Step>
<Step title="Done">
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)
</Step>
</Steps>

View file

@ -0,0 +1,5 @@
---
sidebarTitle: Configure language models
url: /docs/configuration/language-model-providers
title: Configure Language Models
---

View file

@ -0,0 +1,52 @@
---
title: Overview
---
Ask Sourcebot gives you the ability to ask complex questions about your codebase in natural language.
It uses Sourcebots 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 thats rich with inline citations and navigable code snippets.
<CardGroup>
<Card title="Configure language models" icon="robot" href="/docs/configuration/language-model-providers" horizontal="true">
Learn how to connect your language model to Sourcebot
</Card>
<Card title="Index repos" icon="book" href="/docs/connections/overview" horizontal="true">
Learn how to index your repos so you can ask questions about them
</Card>
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
Learn how to self-host Sourcebot in a few simple steps.
</Card>
<Card title="Public demo" icon="globe" href="https://demo.sourcebot.dev/" horizontal="true">
Try Ask Sourcebot on our public demo instance.
</Card>
</CardGroup>
<video
autoPlay
muted
loop
playsInline
className="w-full aspect-video"
src="/images/ask_sourcebot_low_res.mp4"
></video>
# 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).

View file

@ -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.
<Accordion title="Key benefits">
- **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.
</Accordion>
<CardGroup>
<Card title="Index repos" icon="book" href="/docs/connections/overview" horizontal="true">
Learn how to index your repos so you can ask questions about them
</Card>
<Card title="Branches" icon="split" href="/docs/features/search/multi-branch-indexing" horizontal="true">
Learn how to index and search through your branches
</Card>
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
Learn how to self-host Sourcebot in a few simple steps.
</Card>
<Card title="Public demo" icon="globe" href="https://demo.sourcebot.dev/" horizontal="true">
Try Sourcebot's code search on our public demo instance.
</Card>
</CardGroup>
<video
autoPlay
muted
loop
playsInline
className="w-full aspect-video"
src="https://framerusercontent.com/assets/cEqHNSLiMbNeG3bk5xheQWXmKqc.mp4"
></video>

View file

@ -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 theyre hosted
- [Ask Sourcebot](/docs/features/ask): Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations
<CardGroup>
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
@ -30,9 +33,28 @@ title: "Overview"
Find an overview of all Sourcebot features below. For details, see the individual documentation pages.
</Info>
### 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.
<Accordion title="Key benefits">
- **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
</Accordion>
<video
autoPlay
muted
loop
playsInline
className="w-full aspect-video"
src="/images/ask_sourcebot_low_res.mp4"
></video>
### 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.
<Accordion title="Key benefits">
- **Regex support:** Use regular expressions to find code with precision.

Binary file not shown.

View file

@ -1136,6 +1136,886 @@
}
},
"additionalProperties": false
},
"models": {
"type": "array",
"description": "Defines a collection of language models that are available to Sourcebot.",
"items": {
"type": "object",
"title": "LanguageModel",
"definitions": {
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
},
"oneOf": [
{
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
{
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
]
}
}
},
"additionalProperties": false

View file

@ -0,0 +1,879 @@
{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */}
```json
{
"type": "object",
"title": "LanguageModel",
"definitions": {
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
},
"oneOf": [
{
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
{
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
]
}
```

View file

@ -16,6 +16,7 @@
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
"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": {

View file

@ -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;

View file

@ -35,6 +35,11 @@ enum StripeSubscriptionStatus {
INACTIVE
}
enum ChatVisibility {
PRIVATE
PUBLIC
}
model Repo {
id Int @id @default(autoincrement())
name String
@ -183,6 +188,8 @@ model Org {
accountRequests AccountRequest[]
searchContexts SearchContext[]
chats Chat[]
}
enum OrgRole {
@ -276,6 +283,8 @@ model User {
apiKeys ApiKey[]
chats Chat[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@ -311,3 +320,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.
}

View file

@ -1135,6 +1135,886 @@ 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": {
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
},
"oneOf": [
{
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
{
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
]
}
}
},
"additionalProperties": false

View file

@ -11,6 +11,13 @@ export type ConnectionConfig =
| GerritConnectionConfig
| BitbucketConnectionConfig
| GenericGitHostConnectionConfig;
export type LanguageModel =
| OpenAILanguageModel
| AmazonBedrockLanguageModel
| AnthropicLanguageModel
| GoogleGenerativeAILanguageModel
| GoogleVertexLanguageModel
| GoogleVertexAnthropicLanguageModel;
export interface SourcebotConfig {
$schema?: string;
@ -27,6 +34,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.
@ -417,3 +428,243 @@ export interface GenericGitHostConnectionConfig {
url: string;
revisions?: GitRevisions;
}
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;
}
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;
};
/**
* The AWS region. Defaults to the `AWS_REGION` environment variable.
*/
region?: string;
/**
* Optional base URL.
*/
baseUrl?: 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;
}
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;
}
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;
}
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;
}

View file

@ -0,0 +1,878 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
const schema = {
"type": "object",
"title": "LanguageModel",
"definitions": {
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
},
"oneOf": [
{
"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": {
"description": "Optional API key to use with the model. Defaults to the `OPENAI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
},
{
"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
}
]
},
"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."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"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": {
"description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default 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
}
]
},
"baseUrl": {
"type": "string",
"format": "url",
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$",
"description": "Optional base URL."
}
},
"required": [
"provider",
"model"
],
"additionalProperties": false
}
]
} as const;
export { schema as languageModelSchema };

View file

@ -0,0 +1,250 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
export type LanguageModel =
| OpenAILanguageModel
| AmazonBedrockLanguageModel
| AnthropicLanguageModel
| GoogleGenerativeAILanguageModel
| GoogleVertexLanguageModel
| GoogleVertexAnthropicLanguageModel;
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;
}
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;
};
/**
* The AWS region. Defaults to the `AWS_REGION` environment variable.
*/
region?: string;
/**
* Optional base URL.
*/
baseUrl?: 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;
}
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;
}
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;
}
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;
}

View file

@ -12,6 +12,12 @@
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "3.0.0-beta.7",
"@ai-sdk/anthropic": "2.0.0-beta.8",
"@ai-sdk/google": "2.0.0-beta.11",
"@ai-sdk/google-vertex": "3.0.0-beta.15",
"@ai-sdk/openai": "2.0.0-beta.9",
"@ai-sdk/react": "2.0.0-beta.21",
"@auth/prisma-adapter": "^2.7.4",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-cpp": "^6.0.2",
@ -43,9 +49,14 @@
"@hookform/resolvers": "^3.9.0",
"@iconify/react": "^5.1.0",
"@iizukak/codemirror-lang-wgsl": "^0.3.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,14 +93,17 @@
"@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.0-beta.21",
"ajv": "^8.17.1",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0",
@ -117,14 +131,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-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",
@ -139,23 +156,32 @@
"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": {
"@tanstack/eslint-plugin-query": "^5.74.7",
"@testing-library/react-hooks": "^8.0.1",
"@types/micromatch": "^4.0.9",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Anthropic-Icon--Streamline-Svg-Logos" height="24" width="24">
<desc>
Anthropic Icon Streamline Icon: https://streamlinehq.com
</desc>
<path fill="#181818" d="m13.788825 3.932 6.43325 16.136075h3.5279L17.316725 3.932H13.788825Z" stroke-width="0.25"></path>
<path fill="#181818" d="m6.325375 13.682775 2.20125 -5.67065 2.201275 5.67065H6.325375ZM6.68225 3.932 0.25 20.068075h3.596525l1.3155 -3.3886h6.729425l1.315275 3.3886h3.59655L10.371 3.932H6.68225Z" stroke-width="0.25"></path>
</svg>

After

Width:  |  Height:  |  Size: 575 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z"></path></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 28 28"><defs><style>.cls-1{fill:url(#radial-gradient);}</style><radialGradient id="radial-gradient" cx="-576.08" cy="491.7" fx="-576.08" fy="491.7" r="1" gradientTransform="translate(-21336.18 116711.38) rotate(18.68) scale(29.8 -238.74)" gradientUnits="userSpaceOnUse"><stop offset=".07" stop-color="#9168c0"/><stop offset=".34" stop-color="#5684d1"/><stop offset=".67" stop-color="#1ba1e3"/></radialGradient></defs><path class="cls-1" d="M14,28c0-1.94-.37-3.76-1.12-5.46-.72-1.7-1.72-3.19-2.98-4.45-1.26-1.26-2.74-2.25-4.44-2.97-1.7-.75-3.52-1.12-5.46-1.12,1.94,0,3.76-.36,5.46-1.09,1.7-.75,3.19-1.75,4.44-3.01,1.26-1.26,2.25-2.74,2.98-4.44.75-1.7,1.12-3.52,1.12-5.46,0,1.94.36,3.76,1.09,5.46.75,1.7,1.75,3.19,3.01,4.44,1.26,1.26,2.74,2.26,4.45,3.01,1.7.72,3.52,1.09,5.46,1.09-1.94,0-3.76.37-5.46,1.12-1.7.72-3.19,1.71-4.45,2.97s-2.26,2.74-3.01,4.45c-.72,1.7-1.09,3.52-1.09,5.46Z"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>OpenAI icon</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -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<DecorationSet>({

View file

@ -39,6 +39,7 @@ export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }:
webUrl: repoInfoResponse.webUrl,
}}
pathType="tree"
isFileIconVisible={false}
/>
</div>
<Separator />

View file

@ -1,3 +1,5 @@
'use client';
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
import { useCallback } from "react";
@ -13,27 +15,25 @@ 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<BrowseState>;
domain: string;
}
export const useBrowseNavigation = () => {
const router = useRouter();
const domain = useDomain();
const navigateToPath = useCallback(({
export const getBrowsePath = ({
repoName,
revisionName = 'HEAD',
path,
pathType,
highlightRange,
setBrowseState,
}: NavigateToPathOptions) => {
domain,
}: GetBrowsePathProps) => {
const params = new URLSearchParams();
if (highlightRange) {
@ -50,7 +50,35 @@ export const useBrowseNavigation = () => {
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
}
router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`);
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();
const navigateToPath = useCallback(({
repoName,
revisionName = 'HEAD',
path,
pathType,
highlightRange,
setBrowseState,
}: Omit<GetBrowsePathProps, 'domain'>) => {
const browsePath = getBrowsePath({
repoName,
revisionName,
path,
pathType,
highlightRange,
setBrowseState,
domain,
});
router.push(browsePath);
}, [domain, router]);
return {

View file

@ -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<GetBrowsePathProps, 'domain'>) => {
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,
}
}

View file

@ -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 (
<BrowseStateProvider>
<div className="flex flex-col h-screen">
<div className='sticky top-0 left-0 right-0 z-10'>
<TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
domain={params.domain}
domain={domain}
>
<SearchBar
size="sm"
defaultQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
className="w-full"
/>
<Separator />
</div>
</TopBar>
<ResizablePanelGroup
direction="horizontal"
>
@ -53,7 +53,7 @@ export default function Layout({
order={1}
id="code-preview-panel"
>
{codePreviewPanel}
{children}
</ResizablePanel>
<AnimatedResizableHandle />
<BottomPanel

View file

@ -0,0 +1,77 @@
'use client';
import { ResizablePanel } from '@/components/ui/resizable';
import { ChatThread } from '@/features/chat/components/chatThread';
import { LanguageModelInfo, SBChatMessage, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from '@/features/chat/types';
import { RepositoryQuery } from '@/lib/types';
import { CreateUIMessage } from 'ai';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useChatId } from '../../useChatId';
interface ChatThreadPanelProps {
languageModels: LanguageModelInfo[];
repos: RepositoryQuery[];
order: number;
messages: SBChatMessage[];
isChatReadonly: boolean;
}
export const ChatThreadPanel = ({
languageModels,
repos,
order,
messages,
isChatReadonly,
}: ChatThreadPanelProps) => {
// @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 router = useRouter();
const searchParams = useSearchParams();
const [inputMessage, setInputMessage] = useState<CreateUIMessage<SBChatMessage> | undefined>(undefined);
// Use the last user's last message to determine what repos we should select by default.
const [selectedRepos, setSelectedRepos] = useState<string[]>(messages.findLast((message) => message.role === "user")?.metadata?.selectedRepos ?? []);
useEffect(() => {
const setChatState = searchParams.get(SET_CHAT_STATE_QUERY_PARAM);
if (!setChatState) {
return;
}
try {
const { inputMessage, selectedRepos } = JSON.parse(setChatState) as SetChatStatePayload;
setInputMessage(inputMessage);
setSelectedRepos(selectedRepos);
} catch {
console.error('Invalid message in URL');
}
// Remove the message from the URL
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete(SET_CHAT_STATE_QUERY_PARAM);
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
}, [searchParams, router]);
return (
<ResizablePanel
order={order}
id="chat-thread-panel"
defaultSize={85}
>
<div className="flex flex-col h-full w-full">
<ChatThread
id={chatId}
initialMessages={messages}
inputMessage={inputMessage}
languageModels={languageModels}
repos={repos}
selectedRepos={selectedRepos}
onSelectedReposChange={setSelectedRepos}
isChatReadonly={isChatReadonly}
/>
</div>
</ResizablePanel>
)
}

View file

@ -0,0 +1,84 @@
import { getRepos } 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: {
domain: string;
id: string;
};
}
export default async function Page({ params }: PageProps) {
const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(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(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 (
<>
<TopBar
domain={params.domain}
>
<div className="flex flex-row gap-2 items-center">
<span className="text-muted mx-2 select-none">/</span>
<ChatName
name={name}
visibility={visibility}
id={params.id}
isReadonly={isReadonly}
/>
</div>
</TopBar>
<ResizablePanelGroup
direction="horizontal"
>
<ChatSidePanel
order={1}
chatHistory={chatHistory}
isAuthenticated={!!session}
isCollapsedInitially={true}
/>
<AnimatedResizableHandle />
<ChatThreadPanel
languageModels={languageModels}
repos={indexedRepos}
messages={messages}
order={2}
isChatReadonly={isReadonly}
/>
</ResizablePanelGroup>
</>
)
}

View file

@ -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 (
<>
<div className="flex flex-row gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<p
className="text-sm font-medium hover:underline cursor-pointer"
onClick={() => {
setIsRenameDialogOpen(true);
}}
>
{name ?? 'Untitled chat'}
</p>
</TooltipTrigger>
<TooltipContent>
Rename chat
</TooltipContent>
</Tooltip>
{visibility && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<Badge variant="outline" className="cursor-default">
{visibility === ChatVisibility.PUBLIC ? (
<GlobeIcon className="w-3 h-3 mr-1" />
) : (
<LockIcon className="w-3 h-3 mr-1" />
)}
{visibility === ChatVisibility.PUBLIC ? (isReadonly ? 'Public (Read-only)' : 'Public') : 'Private'}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>
{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.'}
</TooltipContent>
</Tooltip>
)}
</div>
<RenameChatDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onRename={onRenameChat}
currentName={name ?? ""}
/>
</>
)
}

View file

@ -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<ImperativePanelHandle>(null);
const router = useRouter();
const { toast } = useToast();
const chatId = useChatId();
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [chatIdToRename, setChatIdToRename] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [chatIdToDelete, setChatIdToDelete] = useState<string | null>(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 (
<>
<ResizablePanel
ref={sidePanelRef}
order={order}
minSize={10}
maxSize={15}
defaultSize={isCollapsed ? 0 : 15}
collapsible={true}
id="chat-side-panel"
onCollapse={() => setIsCollapsed(true)}
onExpand={() => setIsCollapsed(false)}
>
<div className="flex flex-col h-full py-4">
<div className="px-2.5 mb-4">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
router.push(`/${domain}/chat`);
}}
>
<CirclePlusIcon className="w-4 h-4 mr-1" />
New Chat
</Button>
</div>
<ScrollArea className="flex flex-col h-full px-2.5">
<p className="text-sm font-medium mb-4">Recent Chats</p>
<div className="flex flex-col gap-1">
{!isAuthenticated ? (
<div className="flex flex-col">
<p className="text-sm text-muted-foreground mb-4">
<Link
href={`/login?callbackUrl=${encodeURIComponent(`/${domain}/chat`)}`}
className="text-sm text-link hover:underline cursor-pointer"
>
Sign in
</Link> to access your chat history.
</p>
</div>
) : chatHistory.length === 0 ? (
<div className="mx-auto w-full h-52 border border-dashed border-muted-foreground rounded-md flex items-center justify-center p-6">
<p className="text-sm text-muted-foreground text-center">Recent chats will appear here.</p>
</div>
) : chatHistory.map((chat) => (
<div
key={chat.id}
className={cn("group flex flex-row items-center justify-between hover:bg-muted rounded-md px-2 py-1.5 cursor-pointer",
chat.id === chatId && "bg-muted"
)}
onClick={() => {
router.push(`/${domain}/chat/${chat.id}`);
}}
>
<span className="text-sm truncate">{chat.name ?? 'Untitled chat'}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 z-10 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted-accent"
onClick={(e) => {
e.stopPropagation();
}}
>
<EllipsisIcon className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-20"
>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setChatIdToRename(chat.id);
setIsRenameDialogOpen(true);
}}
>
<PencilIcon className="w-4 h-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setChatIdToDelete(chat.id);
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</ScrollArea>
</div>
</ResizablePanel >
{isCollapsed && (
<div className="flex flex-col items-center h-full p-2">
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
sidePanelRef.current?.expand();
}}
>
<ExpandIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
<KeyboardShortcutHint shortcut="⌘ B" />
<Separator orientation="vertical" className="h-4" />
<span>Open side panel</span>
</TooltipContent>
</Tooltip>
</div>
)}
<RenameChatDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onRename={(name) => {
if (chatIdToRename) {
onRenameChat(name, chatIdToRename);
}
}}
currentName={chatHistory?.find((chat) => chat.id === chatIdToRename)?.name ?? ""}
/>
<DeleteChatDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onDelete={() => {
if (chatIdToDelete) {
onDeleteChat(chatIdToDelete);
}
}}
/>
</>
)
}

View file

@ -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 (
<Dialog
open={isOpen}
onOpenChange={onOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
The chat will be deleted and removed from your chat history. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,68 @@
'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 } from "@/features/chat/types";
import { RepositoryQuery } from "@/lib/types";
import { useCallback, useState } from "react";
import { Descendant } from "slate";
import { useLocalStorage } from "usehooks-ts";
interface NewChatPanelProps {
languageModels: LanguageModelInfo[];
repos: RepositoryQuery[];
order: number;
}
export const NewChatPanel = ({
languageModels,
repos,
order,
}: NewChatPanelProps) => {
const [selectedRepos, setSelectedRepos] = useLocalStorage<string[]>("selectedRepos", []);
const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false);
const onSubmit = useCallback((children: Descendant[]) => {
createNewChatThread(children, selectedRepos);
}, [createNewChatThread, selectedRepos]);
return (
<ResizablePanel
order={order}
id="new-chat-panel"
defaultSize={85}
>
<div className="flex flex-col h-full w-full items-center justify-start pt-[20vh]">
<h2 className="text-4xl font-bold mb-8">What can I help you understand?</h2>
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
<CustomSlateEditor>
<ChatBox
onSubmit={onSubmit}
className="min-h-[80px]"
preferredSuggestionsBoxPlacement="bottom-start"
isRedirecting={isLoading}
languageModels={languageModels}
selectedRepos={selectedRepos}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
languageModels={languageModels}
repos={repos}
selectedRepos={selectedRepos}
onSelectedReposChange={setSelectedRepos}
isRepoSelectorOpen={isRepoSelectorOpen}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
</div>
</CustomSlateEditor>
</div>
</div>
</ResizablePanel>
)
}

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
useEffect(() => {
form.reset({
name: currentName,
});
}, [currentName, form]);
const onSubmit = (data: z.infer<typeof formSchema>) => {
onRename(data.name);
form.reset();
onOpenChange(false);
}
return (
<Dialog
open={isOpen}
onOpenChange={onOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Chat</DialogTitle>
<DialogDescription className="sr-only">
{`Rename "${currentName ?? 'untitled chat'}" to a new name.`}
</DialogDescription>
</DialogHeader>
<Form
{...form}
>
<form
className="space-y-4 flex flex-col w-full"
onSubmit={(event) => {
event.stopPropagation();
form.handleSubmit(onSubmit)(event);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem
className="flex flex-col gap-2"
>
<FormControl
>
<Input
{...field}
placeholder="Enter chat name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -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
<NavigationGuardProvider>
<div className="flex flex-col h-screen w-screen">
{children}
</div>
</NavigationGuardProvider>
)
}

View file

@ -0,0 +1,57 @@
import { getRepos } 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: {
domain: string;
};
}
export default async function Page({ params }: PageProps) {
const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(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);
}
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
return (
<>
<TopBar
domain={params.domain}
/>
<ResizablePanelGroup
direction="horizontal"
>
<ChatSidePanel
order={1}
chatHistory={chatHistory}
isAuthenticated={!!session}
isCollapsedInitially={false}
/>
<AnimatedResizableHandle />
<NewChatPanel
languageModels={languageModels}
repos={indexedRepos}
order={2}
/>
</ResizablePanelGroup>
</>
)
}

View file

@ -0,0 +1,8 @@
'use client';
import { useParams } from "next/navigation";
export const useChatId = (): string | undefined => {
const { id: chatId } = useParams<{ id: string }>();
return chatId;
}

View file

@ -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 (
<Button
variant="ghost"
size="sm"
className={cn("h-6 w-6 text-muted-foreground", className)}
onClick={onClick}
aria-label="Copy to clipboard"
>
{copied ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
)
}

View file

@ -0,0 +1,292 @@
'use client';
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ChatBox } from "@/features/chat/components/chatBox";
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
import { LanguageModelInfo } from "@/features/chat/types";
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
import { resetEditor } from "@/features/chat/utils";
import { useDomain } from "@/hooks/useDomain";
import { RepositoryQuery } from "@/lib/types";
import { getDisplayTime } from "@/lib/utils";
import { BrainIcon, FileIcon, LucideIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ReactEditor, useSlate } from "slate-react";
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
import { useLocalStorage } from "usehooks-ts";
// @todo: we should probably rename this to a different type since it sort-of clashes
// with the Suggestion system we have built into the chat box.
type SuggestionType = "understand" | "find" | "summarize";
const suggestionTypes: Record<SuggestionType, {
icon: LucideIcon;
title: string;
description: string;
}> = {
understand: {
icon: BrainIcon,
title: "Understand",
description: "Understand the codebase",
},
find: {
icon: SearchIcon,
title: "Find",
description: "Find the codebase",
},
summarize: {
icon: FileIcon,
title: "Summarize",
description: "Summarize the codebase",
},
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const suggestions: Record<SuggestionType, {
queryText: string;
queryNode?: ReactNode;
openRepoSelector?: boolean;
}[]> = {
understand: [
{
queryText: "How does authentication work in this codebase?",
openRepoSelector: true,
},
{
queryText: "How are API endpoints structured and organized?",
openRepoSelector: true,
},
{
queryText: "How does the build and deployment process work?",
openRepoSelector: true,
},
{
queryText: "How is error handling implemented across the application?",
openRepoSelector: true,
},
],
find: [
{
queryText: "Find examples of different logging libraries used throughout the codebase.",
},
{
queryText: "Find examples of potential security vulnerabilities or authentication issues.",
},
{
queryText: "Find examples of API endpoints and route handlers.",
}
],
summarize: [
{
queryText: "Summarize the purpose of this file @file:",
queryNode: <span>Summarize the purpose of this file <Highlight>@file:</Highlight></span>
},
{
queryText: "Summarize the project structure and architecture.",
openRepoSelector: true,
},
{
queryText: "Provide a quick start guide for ramping up on this codebase.",
openRepoSelector: true,
}
],
}
const MAX_RECENT_CHAT_HISTORY_COUNT = 10;
interface AgenticSearchProps {
searchModeSelectorProps: SearchModeSelectorProps;
languageModels: LanguageModelInfo[];
repos: RepositoryQuery[];
chatHistory: {
id: string;
createdAt: Date;
name: string | null;
}[];
}
export const AgenticSearch = ({
searchModeSelectorProps,
languageModels,
repos,
chatHistory,
}: AgenticSearchProps) => {
const [selectedSuggestionType, _setSelectedSuggestionType] = useState<SuggestionType | undefined>(undefined);
const { createNewChatThread, isLoading } = useCreateNewChatThread();
const dropdownRef = useRef<HTMLDivElement>(null);
const editor = useSlate();
const [selectedRepos, setSelectedRepos] = useLocalStorage<string[]>("selectedRepos", []);
const domain = useDomain();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false);
const setSelectedSuggestionType = useCallback((type: SuggestionType | undefined) => {
_setSelectedSuggestionType(type);
if (type) {
ReactEditor.focus(editor);
}
}, [editor, _setSelectedSuggestionType]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
!dropdownRef.current?.contains(event.target as Node)
) {
setSelectedSuggestionType(undefined);
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [setSelectedSuggestionType]);
return (
<div className="flex flex-col items-center w-full max-w-[800px]">
<div
className="mt-4 w-full border rounded-md shadow-sm"
>
<ChatBox
onSubmit={(children) => {
createNewChatThread(children, selectedRepos);
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
selectedRepos={selectedRepos}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
<Separator />
<div className="relative">
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
languageModels={languageModels}
repos={repos}
selectedRepos={selectedRepos}
onSelectedReposChange={setSelectedRepos}
isRepoSelectorOpen={isRepoSelectorOpen}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
<SearchModeSelector
{...searchModeSelectorProps}
className="ml-auto"
/>
</div>
{selectedSuggestionType && (
<div
ref={dropdownRef}
className="w-full absolute top-10 z-10 drop-shadow-2xl bg-background border rounded-md p-2"
>
<p className="text-muted-foreground text-sm mb-2">
{suggestionTypes[selectedSuggestionType].title}
</p>
{suggestions[selectedSuggestionType].map(({ queryText, queryNode, openRepoSelector }, index) => (
<div
key={index}
className="flex flex-row items-center gap-2 cursor-pointer hover:bg-muted rounded-md px-1 py-0.5"
onClick={() => {
resetEditor(editor);
editor.insertText(queryText);
setSelectedSuggestionType(undefined);
if (openRepoSelector) {
setIsRepoSelectorOpen(true);
} else {
ReactEditor.focus(editor);
}
}}
>
<SearchIcon className="w-4 h-4" />
{queryNode ?? queryText}
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col items-center w-fit gap-6 mt-8 relative">
<div className="flex flex-row items-center gap-4">
{Object.entries(suggestionTypes).map(([type, suggestion], index) => (
<ExampleButton
key={index}
Icon={suggestion.icon}
title={suggestion.title}
onClick={() => {
setSelectedSuggestionType(type as SuggestionType);
}}
/>
))}
</div>
</div>
{chatHistory.length > 0 && (
<div className="flex flex-col items-center w-[80%]">
<Separator className="my-6" />
<span className="font-semibold mb-2">Recent conversations</span>
<div
className="flex flex-col gap-1 w-full"
>
{chatHistory
.slice(0, MAX_RECENT_CHAT_HISTORY_COUNT)
.map((chat) => (
<Link
key={chat.id}
className="flex flex-row items-center justify-between gap-1 w-full rounded-md hover:bg-muted px-2 py-0.5 cursor-pointer group"
href={`/${domain}/chat/${chat.id}`}
>
<span className="text-sm text-muted-foreground group-hover:text-foreground">
{chat.name ?? "Untitled Chat"}
</span>
<span className="text-sm text-muted-foreground group-hover:text-foreground">
{getDisplayTime(chat.createdAt)}
</span>
</Link>
))}
</div>
{chatHistory.length > MAX_RECENT_CHAT_HISTORY_COUNT && (
<Link
href={`/${domain}/chat`}
className="text-sm text-link hover:underline mt-6"
>
View all
</Link>
)}
</div>
)}
</div>
)
}
interface ExampleButtonProps {
Icon: LucideIcon;
title: string;
onClick: () => void;
}
const ExampleButton = ({
Icon,
title,
onClick,
}: ExampleButtonProps) => {
return (
<Button
variant="secondary"
onClick={onClick}
className="h-9"
>
<Icon className="w-4 h-4" />
{title}
</Button>
)
}

View file

@ -0,0 +1,84 @@
'use client';
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { LanguageModelInfo } from "@/features/chat/types";
import { RepositoryQuery } from "@/lib/types";
import { useHotkeys } from "react-hotkeys-hook";
import { useLocalStorage } from "usehooks-ts";
import { AgenticSearch } from "./agenticSearch";
import { PreciseSearch } from "./preciseSearch";
import { SearchMode } from "./toolbar";
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
interface HomepageProps {
initialRepos: RepositoryQuery[];
languageModels: LanguageModelInfo[];
chatHistory: {
id: string;
createdAt: Date;
name: string | null;
}[];
}
export const Homepage = ({
initialRepos,
languageModels,
chatHistory,
}: HomepageProps) => {
const [searchMode, setSearchMode] = useLocalStorage<SearchMode>("search-mode", "precise", { initializeWithValue: false });
const isAgenticSearchEnabled = languageModels.length > 0;
useHotkeys("mod+i", (e) => {
e.preventDefault();
setSearchMode("agentic");
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Switch to agentic search",
});
useHotkeys("mod+p", (e) => {
e.preventDefault();
setSearchMode("precise");
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Switch to precise search",
});
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto">
<SourcebotLogo
className="h-18 md:h-40 w-auto"
/>
</div>
{searchMode === "precise" ? (
<PreciseSearch
initialRepos={initialRepos}
searchModeSelectorProps={{
searchMode: "precise",
isAgenticSearchEnabled,
onSearchModeChange: setSearchMode,
}}
/>
) : (
<CustomSlateEditor>
<AgenticSearch
searchModeSelectorProps={{
searchMode: "agentic",
isAgenticSearchEnabled,
onSearchModeChange: setSearchMode,
}}
languageModels={languageModels}
repos={initialRepos}
chatHistory={chatHistory}
/>
</CustomSlateEditor>
)}
</div>
)
}

View file

@ -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 (
<>
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
<SearchBar
autoFocus={true}
className="border-none pt-0.5 pb-0"
/>
<Separator />
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<SearchModeSelector
{...searchModeSelectorProps}
className="ml-auto"
/>
</div>
</div>
<div className="mt-8">
<RepositorySnapshot
repos={initialRepos}
/>
</div>
<div className="flex flex-col items-center w-fit gap-6">
<Separator className="mt-5" />
<span className="font-semibold">How to search</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<HowToSection
title="Search in files or paths"
>
<QueryExample>
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Filter results"
>
<QueryExample>
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Advanced"
>
<QueryExample>
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
</QueryExample>
</HowToSection>
</div>
<SyntaxReferenceGuideHint />
</div>
</>
)
}
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
return (
<div className="flex flex-col gap-1">
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
{children}
</div>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const QueryExample = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-sm font-mono">
{children}
</span>
)
}
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-gray-500 dark:text-gray-400 ml-3">
{children}
</span>
)
}
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
return (
<Link
href={`/${domain}/search?query=${query}`}
className="cursor-pointer hover:underline"
>
{children}
</Link>
)
}

View file

@ -71,7 +71,7 @@ export function RepositorySnapshot({
{`Search ${indexedRepos.length} `}
<Link
href={`${domain}/repos`}
className="text-blue-500"
className="text-link hover:underline"
>
{indexedRepos.length > 1 ? 'repositories' : 'repository'}
</Link>

View file

@ -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>(searchMode);
return (
<div className={cn("flex flex-row items-center", className)}>
<Select
value={searchMode}
onValueChange={(value) => onSearchModeChange(value as "precise" | "agentic")}
>
<SelectTrigger
className="flex flex-row items-center h-6 mt-0.5 font-mono font-semibold text-xs p-0 w-fit border-none bg-inherit rounded-md"
>
{searchMode === "precise" ? (
<SearchIcon className="w-4 h-4 text-muted-foreground mr-1.5" />
) : (
<MessageCircleIcon className="w-4 h-4 text-muted-foreground mr-1.5" />
)}
<SelectValue>
{searchMode === "precise" ? "Code Search" : "Ask"}
</SelectValue>
</SelectTrigger>
<SelectContent
className="overflow-visible relative"
>
<Tooltip
delayDuration={100}
open={focusedSearchMode === "precise"}
>
<TooltipTrigger asChild>
<div
onMouseEnter={() => setFocusedSearchMode("precise")}
onFocus={() => setFocusedSearchMode("precise")}
>
<SelectItem
value="precise"
className="cursor-pointer"
>
<div className="flex flex-row items-center justify-between w-full gap-1.5">
<span>Search</span>
<div className="flex flex-row items-center gap-2">
<Separator orientation="vertical" className="h-4" />
<KeyboardShortcutHint shortcut="⌘ P" />
</div>
</div>
</SelectItem>
<TooltipContent
side="right"
className="w-64 z-50"
sideOffset={8}
>
<div className="flex flex-col gap-2">
<p className="font-semibold">Code Search</p>
<Separator orientation="horizontal" className="w-full my-0.5" />
<p>Search for exact matches using regular expressions and filters.</p>
<Link
href={PRECISE_SEARCH_DOCS_URL}
className="text-link hover:underline"
>
Docs
</Link>
</div>
</TooltipContent>
</div>
</TooltipTrigger>
</Tooltip>
<Tooltip delayDuration={100} open={focusedSearchMode === "agentic"}>
<TooltipTrigger asChild>
<div
onMouseEnter={() => setFocusedSearchMode("agentic")}
onFocus={() => setFocusedSearchMode("agentic")}
className={cn({
"cursor-not-allowed": !isAgenticSearchEnabled,
})}
>
<SelectItem
value="agentic"
disabled={!isAgenticSearchEnabled}
className={cn({
"cursor-pointer": isAgenticSearchEnabled,
})}
>
<div className="flex flex-row items-center justify-between w-full gap-1.5">
<span>Ask</span>
<div className="flex flex-row items-center gap-2">
<Separator orientation="vertical" className="h-4" />
<KeyboardShortcutHint shortcut="⌘ I" />
</div>
</div>
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="w-64 z-50"
sideOffset={8}
>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
{!isAgenticSearchEnabled && (
<TriangleAlert className="w-4 h-4 flex-shrink-0 text-warning" />
)}
<p className="font-semibold">Ask Sourcebot</p>
</div>
{!isAgenticSearchEnabled && (
<p className="text-destructive">Language model not configured. <Link href={AGENTIC_SEARCH_DOCS_URL} className="text-link hover:underline">See setup instructions.</Link></p>
)}
<Separator orientation="horizontal" className="w-full my-0.5" />
<p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
<Link
href={AGENTIC_SEARCH_DOCS_URL}
className="text-link hover:underline"
>
Docs
</Link>
</div>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
</div>
)
}

View file

@ -153,7 +153,6 @@ export const LightweightCodeHighlighter = memo<LightweightCodeHighlighter>((prop
</span>
)}
<span
className="cm-line"
style={{
flex: 1,
paddingLeft: '6px',

View file

@ -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 { ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast";
import {
@ -14,6 +13,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
import { CopyIconButton } from "./copyIconButton";
interface FileHeaderProps {
path: string;
@ -30,6 +31,9 @@ interface FileHeaderProps {
},
branchDisplayName?: string;
branchDisplayTitle?: string;
isCodeHostIconVisible?: boolean;
isFileIconVisible?: boolean;
repoNameClassName?: string;
}
interface BreadcrumbSegment {
@ -49,6 +53,9 @@ export const PathHeader = ({
branchDisplayName,
branchDisplayTitle,
pathType = 'blob',
isCodeHostIconVisible = true,
isFileIconVisible = true,
repoNameClassName,
}: FileHeaderProps) => {
const info = getCodeHostInfoForRepo({
name: repo.name,
@ -59,8 +66,6 @@ export const PathHeader = ({
const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const breadcrumbsRef = useRef<HTMLDivElement>(null);
const [visibleSegmentCount, setVisibleSegmentCount] = useState<number | null>(null);
@ -170,9 +175,8 @@ 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) => {
@ -204,6 +208,8 @@ export const PathHeader = ({
return (
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
{isCodeHostIconVisible && (
<>
{info?.icon ? (
<a href={info.repoLink} target="_blank" rel="noopener noreferrer">
<Image
@ -215,8 +221,11 @@ export const PathHeader = ({
) : (
<LaptopIcon className="w-4 h-4" />
)}
</>
)}
<div
className="font-medium cursor-pointer hover:underline"
className={cn("font-medium cursor-pointer hover:underline", repoNameClassName)}
onClick={() => navigateToPath({
repoName: repo.name,
path: '',
@ -269,8 +278,11 @@ export const PathHeader = ({
)}
{visibleSegments.map((segment, index) => (
<div key={segment.fullPath} className="flex items-center">
{(isFileIconVisible && index === visibleSegments.length - 1) && (
<VscodeFileIcon fileName={segment.name} className="h-4 w-4 mr-1" />
)}
<span
className={clsx(
className={cn(
"font-mono text-sm truncate cursor-pointer hover:underline",
)}
onClick={() => onBreadcrumbClick(segment)}
@ -283,18 +295,10 @@ export const PathHeader = ({
</div>
))}
</div>
<button
className="ml-2 p-1 rounded transition-colors flex-shrink-0"
onClick={onCopyPath}
aria-label="Copy file path"
type="button"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</button>
<CopyIconButton
onCopy={onCopyPath}
className="ml-2"
/>
</div>
</div>
)

View file

@ -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.
<DropdownMenu onOpenChange={(isOpen) => {
if (isOpen) {
update();
}
}}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
<Settings className="h-4 w-4" />

View file

@ -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 = () => {
<DialogHeader>
<DialogTitle>Syntax Reference Guide</DialogTitle>
<DialogDescription className="text-sm text-foreground">
Queries consist of space-separated regular expressions. Wrapping expressions in <Code>{`""`}</Code> 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 <CodeSnippet>{`""`}</CodeSnippet> combines them. By default, a file must have at least one match for each expression to be included.
</DialogDescription>
</DialogHeader>
<Table>
@ -78,23 +78,23 @@ export const SyntaxReferenceGuide = () => {
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code>foo</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>foo</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo/</CodeSnippet></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>and</b> <Code>/bar/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>foo bar</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo/</CodeSnippet> <b>and</b> <CodeSnippet>/bar/</CodeSnippet></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>{`"foo bar"`}</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo bar/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>{`"foo bar"`}</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo bar/</CodeSnippet></TableCell>
</TableRow>
</TableBody>
</Table>
<Separator className="my-2"/>
<p className="text-sm">
{`Multiple expressions can be or'd together with `}<Code>or</Code>, negated with <Code>-</Code>, or grouped with <Code>()</Code>.
{`Multiple expressions can be or'd together with `}<CodeSnippet>or</CodeSnippet>, negated with <CodeSnippet>-</CodeSnippet>, or grouped with <CodeSnippet>()</CodeSnippet>.
</p>
<Table>
<TableHeader>
@ -105,23 +105,23 @@ export const SyntaxReferenceGuide = () => {
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code>foo <Highlight>or</Highlight> bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>or</b> <Code>/bar/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>foo <Highlight>or</Highlight> bar</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo/</CodeSnippet> <b>or</b> <CodeSnippet>/bar/</CodeSnippet></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo -bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> but <b>not</b> <Code>/bar/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>foo -bar</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo/</CodeSnippet> but <b>not</b> <CodeSnippet>/bar/</CodeSnippet></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo (bar <Highlight>or</Highlight> baz)</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>and</b> either <Code>/bar/</Code> <b>or</b> <Code>/baz/</Code></TableCell>
<TableCell className="py-2"><CodeSnippet>foo (bar <Highlight>or</Highlight> baz)</CodeSnippet></TableCell>
<TableCell className="py-2">Match files with regex <CodeSnippet>/foo/</CodeSnippet> <b>and</b> either <CodeSnippet>/bar/</CodeSnippet> <b>or</b> <CodeSnippet>/baz/</CodeSnippet></TableCell>
</TableRow>
</TableBody>
</Table>
<Separator className="my-2"/>
<p className="text-sm">
Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the <Code>-</Code> prefix.
Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the <CodeSnippet>-</CodeSnippet> prefix.
</p>
<Table>
@ -134,87 +134,87 @@ export const SyntaxReferenceGuide = () => {
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code><Highlight>file:</Highlight></Code></TableCell>
<TableCell className="py-2"><CodeSnippet><Highlight>file:</Highlight></CodeSnippet></TableCell>
<TableCell className="py-2">Filter results from filepaths that match the regex. By default all files are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
<CodeSnippet
title="Filter results to filepaths that match regex /README/"
>
<Highlight>file:</Highlight>README
</Code>
<Code
</CodeSnippet>
<CodeSnippet
title="Filter results to filepaths that match regex /my file/"
>
<Highlight>file:</Highlight>{`"my file"`}
</Code>
<Code
</CodeSnippet>
<CodeSnippet
title="Ignore results from filepaths match regex /test\.ts$/"
>
<Highlight>-file:</Highlight>test\.ts$
</Code>
</CodeSnippet>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>repo:</Highlight></Code></TableCell>
<TableCell className="py-2"><CodeSnippet><Highlight>repo:</Highlight></CodeSnippet></TableCell>
<TableCell className="py-2">Filter results from repos that match the regex. By default all repos are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
<CodeSnippet
title="Filter results to repos that match regex /linux/"
>
<Highlight>repo:</Highlight>linux
</Code>
<Code
</CodeSnippet>
<CodeSnippet
title="Ignore results from repos that match regex /^web\/.*/"
>
<Highlight>-repo:</Highlight>^web/.*
</Code>
</CodeSnippet>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>rev:</Highlight></Code></TableCell>
<TableCell className="py-2"><CodeSnippet><Highlight>rev:</Highlight></CodeSnippet></TableCell>
<TableCell className="py-2">Filter results from a specific branch or tag. By default <b>only</b> the default branch is searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
<CodeSnippet
title="Filter results to branches that match regex /beta/"
>
<Highlight>rev:</Highlight>beta
</Code>
</CodeSnippet>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>lang:</Highlight></Code></TableCell>
<TableCell className="py-2"><CodeSnippet><Highlight>lang:</Highlight></CodeSnippet></TableCell>
<TableCell className="py-2">Filter results by language (as defined by <Link className="text-blue-500" href={LINGUIST_LINK}>linguist</Link>). By default all languages are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
<CodeSnippet
title="Filter results to TypeScript files"
>
<Highlight>lang:</Highlight>TypeScript
</Code>
<Code
</CodeSnippet>
<CodeSnippet
title="Ignore results from YAML files"
>
<Highlight>-lang:</Highlight>YAML
</Code>
</CodeSnippet>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>sym:</Highlight></Code></TableCell>
<TableCell className="py-2"><CodeSnippet><Highlight>sym:</Highlight></CodeSnippet></TableCell>
<TableCell className="py-2">Match symbol definitions created by <Link className="text-blue-500" href={CTAGS_LINK}>universal ctags</Link> at index time.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
<CodeSnippet
title="Filter results to symbols that match regex /\bmain\b/"
>
<Highlight>sym:</Highlight>\bmain\b
</Code>
</CodeSnippet>
</div>
</TableCell>
</TableRow>
@ -225,17 +225,6 @@ export const SyntaxReferenceGuide = () => {
)
}
const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
return (
<code
className={clsx("bg-gray-100 dark:bg-gray-700 w-fit rounded-md font-mono px-2 py-0.5", className)}
title={title}
>
{children}
</code>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">

View file

@ -2,19 +2,20 @@ 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 (
<div className='sticky top-0 left-0 right-0 z-10'>
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
<div className="grow flex flex-row gap-4 items-center">
<Link
@ -32,15 +33,13 @@ export const TopBar = ({
alt={"Sourcebot logo"}
/>
</Link>
<SearchBar
size="sm"
defaultQuery={defaultSearchQuery}
className="w-full"
/>
{children}
</div>
<SettingsDropdown
menuButtonClassName="w-8 h-8"
/>
</div>
<Separator />
</div>
)
}

View file

@ -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 (
<code
className={cn("bg-gray-100 dark:bg-gray-700 w-fit rounded-md font-mono px-2 py-0.5", className)}
title={title}
>
{children}
</code>
)
}
import { CodeSnippet } from "@/app/components/codeSnippet";
export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
{
@ -30,7 +19,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
selectionText: "<owner>/<repo name>",
description: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
@ -38,7 +27,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"vercel/next.js",
"torvalds/linux"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -56,7 +45,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
selectionText: "<organization name>",
description: (
<div className="flex flex-col">
<span>Add an organization to sync with. All repositories in the organization visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span>Add an organization to sync with. All repositories in the organization visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
@ -64,7 +53,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"sourcebot",
"vercel"
].map((org) => (
<Code key={org}>{org}</Code>
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
@ -82,7 +71,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
selectionText: "<username>",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All repositories that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span>Add a user to sync with. All repositories that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
@ -90,7 +79,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"torvalds",
"octocat"
].map((org) => (
<Code key={org}>{org}</Code>
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
@ -103,7 +92,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
}),
name: "Set url to GitHub instance",
selectionText: "https://github.example.com",
description: <span>Set a custom GitHub host. Defaults to <Code>https://github.com</Code>.</span>
description: <span>Set a custom GitHub host. Defaults to <CodeSnippet>https://github.com</CodeSnippet>.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
@ -127,7 +116,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"my-org/docs*",
"my-org/test*"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -155,7 +144,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"docs",
"ci"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -180,7 +169,7 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
"docs",
"ci"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -223,14 +212,14 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
selectionText: "<project name>",
description: (
<div className="flex flex-col">
<span>Add a individual project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a individual project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"gitlab-org/gitlab",
"corp/team-project",
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -248,14 +237,14 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
selectionText: "<username>",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All projects that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span>Add a user to sync with. All projects that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"jane-doe",
"torvalds"
].map((org) => (
<Code key={org}>{org}</Code>
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
@ -273,14 +262,14 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
selectionText: "<group name>",
description: (
<div className="flex flex-col">
<span>Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span>Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"my-group",
"path/to/subgroup"
].map((org) => (
<Code key={org}>{org}</Code>
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
@ -293,7 +282,7 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
}),
name: "Set url to GitLab instance",
selectionText: "https://gitlab.example.com",
description: <span>Set a custom GitLab host. Defaults to <Code>https://gitlab.com</Code>.</span>
description: <span>Set a custom GitLab host. Defaults to <CodeSnippet>https://gitlab.com</CodeSnippet>.</span>
},
{
fn: (previous: GitlabConnectionConfig) => ({
@ -301,7 +290,7 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
all: true,
}),
name: "Sync all projects",
description: <span>Sync all projects visible to the provided <Code>token</Code> (if any). Only available when using a self-hosted GitLab instance.</span>
description: <span>Sync all projects visible to the provided <CodeSnippet>token</CodeSnippet> (if any). Only available when using a self-hosted GitLab instance.</span>
},
{
fn: (previous: GitlabConnectionConfig) => ({
@ -325,7 +314,7 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
"docs/**",
"**/tests/**",
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -403,7 +392,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
selectionText: "username",
description: (
<div className="flex flex-col">
<span>Username to use for authentication. This is only required if you&apos;re using an App Password (stored in <Code>token</Code>) for authentication.</span>
<span>Username to use for authentication. This is only required if you&apos;re using an App Password (stored in <CodeSnippet>token</CodeSnippet>) for authentication.</span>
</div>
)
},
@ -419,7 +408,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
selectionText: "myWorkspace",
description: (
<div className="flex flex-col">
<span>Add a workspace to sync with. Ensure the workspace is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a workspace to sync with. Ensure the workspace is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
@ -435,7 +424,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
selectionText: "myWorkspace/myRepo",
description: (
<div className="flex flex-col">
<span>Add an individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
<span>Add an individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
@ -451,7 +440,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
@ -506,14 +495,14 @@ export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionCon
selectionText: "myProject/myRepo",
description: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"PROJ/repo-name",
"MYPROJ/api"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
@ -531,7 +520,7 @@ export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionCon
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
@ -554,7 +543,7 @@ export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionCon
"myProject/myExcludedRepo",
"myProject2/*"
].map((repo) => (
<Code key={repo}>{repo}</Code>
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>

View file

@ -1,16 +1,14 @@
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 { 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 { Footer } from "@/app/components/footer";
import { getOrgFromDomain } from "@/data/org";
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
import { isServiceError } 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";
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
@ -18,7 +16,21 @@ export default async function Home({ params: { domain } }: { params: { domain: s
return <PageNotFound />
}
const session = await auth();
const models = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(domain);
const chatHistory = session ? await getUserChatHistory(domain) : [];
if (isServiceError(repos)) {
throw new ServiceErrorException(repos);
}
if (isServiceError(chatHistory)) {
throw new ServiceErrorException(chatHistory);
}
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
@ -26,123 +38,13 @@ export default async function Home({ params: { domain } }: { params: { domain: s
domain={domain}
/>
<UpgradeToast />
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto">
<SourcebotLogo
className="h-18 md:h-40 w-auto"
<Homepage
initialRepos={indexedRepos}
languageModels={models}
chatHistory={chatHistory}
/>
</div>
<SearchBar
autoFocus={true}
className="mt-4 w-full max-w-[800px]"
/>
<div className="mt-8">
<RepositorySnapshot
repos={isServiceError(repos) ? [] : repos}
/>
</div>
<div className="flex flex-col items-center w-fit gap-6">
<Separator className="mt-5" />
<span className="font-semibold">How to search</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<HowToSection
title="Search in files or paths"
>
<QueryExample>
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Filter results"
>
<QueryExample>
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Advanced"
>
<QueryExample>
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
</QueryExample>
</HowToSection>
</div>
<SyntaxReferenceGuideHint />
</div>
</div>
<Footer />
</div>
)
}
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
return (
<div className="flex flex-col gap-1">
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
{children}
</div>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const QueryExample = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-sm font-mono">
{children}
</span>
)
}
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-gray-500 dark:text-gray-400 ml-3">
{children}
</span>
)
}
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
return (
<Link
href={`/${domain}/search?query=${query}`}
className="cursor-pointer hover:underline"
>
{children}
</Link>
)
}

View file

@ -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 (
<div className="flex flex-col h-screen overflow-clip">
{/* TopBar */}
<div className="sticky top-0 left-0 right-0 z-10">
<TopBar
defaultSearchQuery={searchQuery}
domain={domain}
>
<SearchBar
size="sm"
defaultQuery={searchQuery}
className="w-full"
/>
<Separator />
</div>
</TopBar>
{(isSearchLoading) ? (
<div className="flex flex-col items-center justify-center h-full gap-2">

View file

@ -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) => {
<AlertDialogHeader>
<AlertDialogTitle>Delete Secret</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the secret <Code>{secretToDelete?.key}</Code>? Any connections that use this secret will <strong>fail to sync.</strong>
Are you sure you want to delete the secret <CodeSnippet>{secretToDelete?.key}</CodeSnippet>? Any connections that use this secret will <strong>fail to sync.</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -155,14 +156,3 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {
</div>
)
}
const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
return (
<code
className={cn("bg-gray-100 dark:bg-gray-700 w-fit rounded-md font-mono px-2 py-0.5", className)}
title={title}
>
{children}
</code>
)
}

View file

@ -0,0 +1,426 @@
import { sew, withAuth, withOrgMembership } from "@/actions";
import { env } from "@/env.mjs";
import { _getConfiguredLanguageModelsFull, updateChatMessages, updateChatName } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, SBChatMessage } 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 { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createVertex } from '@ai-sdk/google-vertex';
import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
import { createOpenAI, OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { getTokenFromConfig } from "@sourcebot/crypto";
import { OrgRole } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger";
import { LanguageModel } from "@sourcebot/schemas/v3/index.type";
import {
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
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, selectedRepos, languageModelId } = parsed.data;
const response = await chatHandler({
messages,
id,
selectedRepos,
languageModelId,
}, domain);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return response;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMessageStreamWriter<SBChatMessage>, options: UIMessageStreamOptions<SBChatMessage> = {}) => {
await new Promise<void>((resolve) => writer.merge(stream.toUIMessageStream({
...options,
onFinish: async () => {
resolve();
}
})));
}
interface ChatHandlerProps {
messages: SBChatMessage[];
id: string;
selectedRepos: string[];
languageModelId: string;
}
const chatHandler = ({ messages, id, selectedRepos, languageModelId }: ChatHandlerProps, domain: string) => sew(async () =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
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.",
});
}
const latestMessage = messages[messages.length - 1];
const sources = latestMessage.parts
.filter((part) => part.type === 'data-source')
.map((part) => part.data);
// 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, headers } = await getAISDKLanguageModelAndOptions(languageModelConfig, org.id);
// @todo: refactor this
if (
messages.length === 1 &&
messages[0].role === "user" &&
messages[0].parts.length >= 1 &&
messages[0].parts[0].type === 'text'
) {
const content = messages[0].parts[0].text;
const title = await generateChatTitle(content, model);
if (title) {
updateChatName({
chatId: id,
name: title,
}, domain);
}
else {
logger.error("Failed to generate chat title.");
}
}
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);
try {
const stream = createUIMessageStream<SBChatMessage>({
execute: async ({ writer }) => {
writer.write({
type: 'start',
});
const startTime = new Date();
const researchStream = await createAgentStream({
model,
providerOptions,
headers,
inputMessages: messageHistory,
inputSources: sources,
selectedRepos,
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: languageModelConfig.displayName ?? languageModelConfig.model,
traceId,
}
})
writer.write({
type: 'finish',
});
},
onError: errorHandler,
originalMessages: messages,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
}, domain);
},
});
return createUIMessageStreamResponse({
stream,
});
} catch (error) {
logger.error("Error:", error)
logger.error("Error stack:", error instanceof Error ? error.stack : "No stack trace")
Sentry.captureException(error);
return serviceErrorResponse({
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.UNEXPECTED_ERROR,
message: error instanceof Error ? error.message : "Unknown error",
});
}
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
));
const generateChatTitle = async (message: string, model: AISDKLanguageModelV2) => {
try {
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,
maxOutputTokens: 20,
});
return result.text;
} catch (error) {
logger.error("Error generating summary:", error)
return undefined;
}
}
const getAISDKLanguageModelAndOptions = async (config: LanguageModel, orgId: number): Promise<{
model: AISDKLanguageModelV2,
providerOptions?: Record<string, Record<string, JSONValue>>,
headers?: Record<string, string>,
}> => {
const { provider, model: modelId } = config;
switch (provider) {
case 'anthropic': {
const anthropic = createAnthropic({
baseURL: config.baseUrl,
...(config.token ? {
apiKey: (await getTokenFromConfig(config.token, orgId, prisma)),
} : {
apiKey: env.ANTHROPIC_API_KEY,
}),
});
return {
model: anthropic(modelId),
providerOptions: {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: env.ANTHROPIC_THINKING_BUDGET_TOKENS,
}
} satisfies AnthropicProviderOptions,
},
headers: {
// @see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
'anthropic-beta': 'interleaved-thinking-2025-05-14',
},
};
}
case 'openai': {
const openai = createOpenAI({
baseURL: config.baseUrl,
...(config.token ? {
apiKey: (await getTokenFromConfig(config.token, orgId, prisma)),
} : {
apiKey: env.OPENAI_API_KEY,
}),
});
return {
model: openai(modelId),
providerOptions: {
openai: {
reasoningEffort: 'high'
} satisfies OpenAIResponsesProviderOptions,
},
};
}
case 'google-generative-ai': {
const google = createGoogleGenerativeAI({
baseURL: config.baseUrl,
...(config.token ? {
apiKey: (await getTokenFromConfig(config.token, orgId, prisma)),
} : {
apiKey: env.GOOGLE_GENERATIVE_AI_API_KEY,
}),
});
return {
model: google(modelId),
};
}
case 'amazon-bedrock': {
const aws = createAmazonBedrock({
baseURL: config.baseUrl,
region: config.region ?? env.AWS_REGION,
...(config.accessKeyId ? {
accessKeyId: (await getTokenFromConfig(config.accessKeyId, orgId, prisma)),
} : {
accessKeyId: env.AWS_ACCESS_KEY_ID,
}),
...(config.accessKeySecret ? {
secretAccessKey: (await getTokenFromConfig(config.accessKeySecret, orgId, prisma)),
} : {
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
}),
});
return {
model: aws(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),
}
} : {}),
});
return {
model: vertex(modelId),
};
}
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),
}
} : {}),
});
return {
model: vertexAnthropic(modelId),
};
}
}
}
const errorHandler = (error: unknown) => {
if (error == null) {
return 'unknown error';
}
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return JSON.stringify(error);
}

View file

@ -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);
}

View file

@ -0,0 +1,12 @@
import { cn } from "@/lib/utils"
export const CodeSnippet = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
return (
<code
className={cn("bg-gray-100 dark:bg-gray-700 w-fit rounded-md px-2 py-0.5 font-medium font-mono", className)}
title={title}
>
{children}
</code>
)
}

View file

@ -1,245 +0,0 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
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 "../[domain]/components/syntaxGuideProvider";
const LINGUIST_LINK = "https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml";
const CTAGS_LINK = "https://ctags.io/";
export const SyntaxReferenceGuide = () => {
const { isOpen, onOpenChanged } = useSyntaxGuide();
const previousFocusedElement = useRef<HTMLElement | null>(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 (
<Dialog
open={isOpen}
onOpenChange={handleOpenChange}
>
<DialogContent
className="max-h-[80vh] max-w-[700px] overflow-scroll"
>
<DialogHeader>
<DialogTitle>Syntax Reference Guide</DialogTitle>
<DialogDescription className="text-sm text-foreground">
Queries consist of space-separated regular expressions. Wrapping expressions in <Code>{`""`}</Code> combines them. By default, a file must have at least one match for each expression to be included.
</DialogDescription>
</DialogHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead className="py-2">Example</TableHead>
<TableHead className="py-2">Explanation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code>foo</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>and</b> <Code>/bar/</Code></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>{`"foo bar"`}</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo bar/</Code></TableCell>
</TableRow>
</TableBody>
</Table>
<Separator className="my-2"/>
<p className="text-sm">
{`Multiple expressions can be or'd together with `}<Code>or</Code>, negated with <Code>-</Code>, or grouped with <Code>()</Code>.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="py-2">Example</TableHead>
<TableHead className="py-2">Explanation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code>foo <Highlight>or</Highlight> bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>or</b> <Code>/bar/</Code></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo -bar</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> but <b>not</b> <Code>/bar/</Code></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code>foo (bar <Highlight>or</Highlight> baz)</Code></TableCell>
<TableCell className="py-2">Match files with regex <Code>/foo/</Code> <b>and</b> either <Code>/bar/</Code> <b>or</b> <Code>/baz/</Code></TableCell>
</TableRow>
</TableBody>
</Table>
<Separator className="my-2"/>
<p className="text-sm">
Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the <Code>-</Code> prefix.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="py-2">Prefix</TableHead>
<TableHead className="py-2">Description</TableHead>
<TableHead className="py-2 w-[175px]">Example</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="py-2"><Code><Highlight>file:</Highlight></Code></TableCell>
<TableCell className="py-2">Filter results from filepaths that match the regex. By default all files are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
title="Filter results to filepaths that match regex /README/"
>
<Highlight>file:</Highlight>README
</Code>
<Code
title="Filter results to filepaths that match regex /my file/"
>
<Highlight>file:</Highlight>{`"my file"`}
</Code>
<Code
title="Ignore results from filepaths match regex /test\.ts$/"
>
<Highlight>-file:</Highlight>test\.ts$
</Code>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>repo:</Highlight></Code></TableCell>
<TableCell className="py-2">Filter results from repos that match the regex. By default all repos are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
title="Filter results to repos that match regex /linux/"
>
<Highlight>repo:</Highlight>linux
</Code>
<Code
title="Ignore results from repos that match regex /^web\/.*/"
>
<Highlight>-repo:</Highlight>^web/.*
</Code>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>rev:</Highlight></Code></TableCell>
<TableCell className="py-2">Filter results from a specific branch or tag. By default <b>only</b> the default branch is searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
title="Filter results to branches that match regex /beta/"
>
<Highlight>rev:</Highlight>beta
</Code>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>lang:</Highlight></Code></TableCell>
<TableCell className="py-2">Filter results by language (as defined by <Link className="text-blue-500" href={LINGUIST_LINK}>linguist</Link>). By default all languages are searched.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
title="Filter results to TypeScript files"
>
<Highlight>lang:</Highlight>TypeScript
</Code>
<Code
title="Ignore results from YAML files"
>
<Highlight>-lang:</Highlight>YAML
</Code>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-2"><Code><Highlight>sym:</Highlight></Code></TableCell>
<TableCell className="py-2">Match symbol definitions created by <Link className="text-blue-500" href={CTAGS_LINK}>universal ctags</Link> at index time.</TableCell>
<TableCell className="py-2">
<div className="flex flex-col gap-1">
<Code
title="Filter results to symbols that match regex /\bmain\b/"
>
<Highlight>sym:</Highlight>\bmain\b
</Code>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</DialogContent>
</Dialog>
)
}
const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
return (
<code
className={clsx("bg-gray-100 dark:bg-gray-700 w-fit rounded-md font-mono px-2 py-0.5", className)}
title={title}
>
{children}
</code>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}

View file

@ -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 <Icon icon={iconName} className={cn("w-4 h-4 flex-shrink-0", className)} />;
}

View file

@ -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 <Icon icon={iconName} className={cn("w-4 h-4 flex-shrink-0", className)} />;
}

View file

@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@import "./codemirror-styles.css";
@layer base {
:root {
--background: hsl(0 0% 100%);
@ -17,6 +19,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 +42,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 +87,13 @@
--editor-tag-number: #219;
--editor-tag-regexp: #e40;
--editor-tag-variable-local: #30a;
--chat-reference: #02255f11;
--chat-reference-hover: #02225f22;
--chat-reference-selected: #3b83f640;
--chat-reference-selected-border: #e052b8;
--warning: #ca8a04;
}
.dark {
@ -99,9 +110,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 +132,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 +174,13 @@
--editor-tag-number: #e5c07b;
--editor-tag-regexp: #56b6c2;
--editor-tag-variable-local: #61afef;
--chat-reference: #2c313aad;
--chat-reference-hover: #374151;
--chat-reference-selected: #1e3b8a87;
--chat-reference-selected-border: #60a5fa;
--warning: #fde047;
}
}
@ -203,6 +223,34 @@
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;
}
.cm-editor.cm-focused {
outline: none !important;
}
@ -215,8 +263,6 @@
text-overflow: ellipsis;
}
@layer base {
* {
@apply border-border;

View file

@ -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",
};

View file

@ -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<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -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 (
<ResizableHandle
className="w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground"
className={cn("w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground", className)}
/>
)
}

View file

@ -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 }

View file

@ -14,7 +14,8 @@ const ScrollArea = React.forwardRef<
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{/* @see: https://github.com/radix-ui/primitives/issues/926#issuecomment-1447283516 */}
<ScrollAreaPrimitive.Viewport className="h-full w-full [&>div]:!block rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />

View file

@ -129,7 +129,7 @@ const SelectItem = React.forwardRef<
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{children}
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

View file

@ -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
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View file

@ -101,7 +101,6 @@ export const SymbolHoverPopup: React.FC<SymbolHoverPopupProps> = ({
useHotkeys('alt+shift+f12', () => {
if (symbolInfo?.symbolName) {
console.log('here!');
onFindReferences(symbolInfo.symbolName);
}
}, {

View file

@ -91,11 +91,30 @@ 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),
OPENAI_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(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: 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(),
},
// @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in
@ -110,6 +129,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 +139,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,

View file

@ -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.

View file

@ -0,0 +1,302 @@
'use server';
import { sew, withAuth, withOrgMembership } from "@/actions";
import { env } from "@/env.mjs";
import { chatIsReadonly, notFound, ServiceError } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { ChatVisibility, OrgRole, Prisma } from "@sourcebot/db";
import fs from 'fs';
import path from 'path';
import { LanguageModelInfo, SBChatMessage } from "./types";
import { loadConfig } from "@sourcebot/shared";
import { LanguageModel } from "@sourcebot/schemas/v3/languageModel.type";
import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
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 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: {
type: feedbackType,
timestamp: new Date().toISOString(),
userId: userId,
}
}
};
}
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<LanguageModelInfo[]> => {
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<LanguageModel[]> => {
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 [];
}
}

View file

@ -0,0 +1,262 @@
import { env } from "@/env.mjs";
import { getFileSource } from "@/features/search/fileSourceApi";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
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 } 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<string, string>;
selectedRepos: 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<any> => {
return ({ steps }) => steps.length >= stepCount;
}
export const createAgentStream = async ({
model,
providerOptions,
headers,
inputMessages,
inputSources,
selectedRepos,
onWriteSource,
traceId,
}: AgentOptions) => {
const baseSystemPrompt = createBaseSystemPrompt({
selectedRepos,
});
const stream = streamText({
model,
providerOptions,
headers,
system: baseSystemPrompt,
messages: inputMessages,
tools: {
[toolNames.searchCode]: createCodeSearchTool(selectedRepos),
[toolNames.readFiles]: readFilesTool,
[toolNames.findSymbolReferences]: findSymbolReferencesTool,
[toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool,
},
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(({ output, toolName }) => {
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,
},
},
});
return stream;
}
interface BaseSystemPromptOptions {
selectedRepos: string[];
}
export const createBaseSystemPrompt = ({
selectedRepos,
}: 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.
<workflow>
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
</workflow>
<available_repositories>
The user has selected the following repositories for analysis:
${selectedRepos.map(repo => `- ${repo}`).join('\n')}
</available_repositories>
<research_phase_instructions>
During the research phase, you have these tools available:
- \`${toolNames.searchCode}\`: Search for code patterns, functions, or text across repositories
- \`${toolNames.readFiles}\`: Read the contents of specific files
- \`${toolNames.findSymbolReferences}\`: Find where symbols are referenced
- \`${toolNames.findSymbolDefinitions}\`: Find where symbols are defined
Use these tools to gather comprehensive context before answering. Always explain why you're using each tool.
</research_phase_instructions>
${answerInstructions}
`;
}
const answerInstructions = `
<answer_instructions>
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({ fileName: 'filename'})}\` or \`${fileReferenceToString({ fileName: '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({ fileName: 'auth.ts' })}\`)
- Function names (e.g., "The \`getRepos()\` function" must include \`${fileReferenceToString({ fileName: 'auth.ts', range: { startLine: 15, endLine: 20 } })}\`)
- Variable names (e.g., "The \`suggestionQuery\` variable" must include \`${fileReferenceToString({ fileName: 'search.ts', range: { startLine: 42, endLine: 42 } })}\`)
- Code patterns (e.g., "using \`file:\${suggestionQuery}\` pattern" must include \`${fileReferenceToString({ fileName: 'search.ts', range: { startLine: 10, endLine: 15 } })}\`)
- Any code snippet or line you're explaining
- Class names, method calls, imports, etc.
- 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({ fileName: 'auth.ts', range: { startLine: 135, endLine: 140 } })}. The system supports multiple authentication providers and implements organization-based authorization with role-defined permissions.
\`\`\`
</answer_instructions>
`;
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 => `<file path="${file.path}" repository="${file.repo}" language="${file.language}" revision="${file.revision}">
${addLineNumbers(file.source)}
</file>`).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.
}, SINGLE_TENANT_ORG_DOMAIN);
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,
}
}

View file

@ -0,0 +1,407 @@
'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 } 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";
interface ChatBoxProps {
onSubmit: (children: Descendant[], editor: CustomEditor) => void;
onStop?: () => void;
preferredSuggestionsBoxPlacement?: "top-start" | "bottom-start";
className?: string;
isRedirecting?: boolean;
isGenerating?: boolean;
languageModels: LanguageModelInfo[];
selectedRepos: string[];
onRepoSelectorOpenChanged: (isOpen: boolean) => void;
}
export const ChatBox = ({
onSubmit: _onSubmit,
onStop,
preferredSuggestionsBoxPlacement = "bottom-start",
className,
isRedirecting,
isGenerating,
languageModels,
selectedRepos,
onRepoSelectorOpenChanged,
}: ChatBoxProps) => {
const suggestionsBoxRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const editor = useSlate();
const { suggestionQuery, suggestionMode, range } = useSuggestionModeAndQuery();
const { suggestions, isLoading } = useSuggestionsData({
suggestionMode,
suggestionQuery,
selectedRepos,
});
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 <MentionComponent {...props as RenderElementPropsFor<MentionElement>} />
default:
return <DefaultElement {...props} />
}
}, []);
const renderLeaf = useCallback((props: RenderLeafProps) => {
return <Leaf {...props} />
}, []);
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 (selectedRepos.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,
selectedRepos.length,
selectedLanguageModel,
])
const onSubmit = useCallback(() => {
if (isSubmitDisabled) {
if (isSubmitDisabledReason === "no-repos-selected") {
toast({
description: "⚠️ One or more repositories must be selected.",
variant: "destructive",
});
onRepoSelectorOpenChanged(true);
}
return;
}
_onSubmit(editor.children, editor);
}, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onRepoSelectorOpenChanged]);
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<HTMLDivElement>) => {
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 (
<div
className={cn("flex flex-col justify-between gap-0.5 w-full px-3 py-2", className)}
>
<Editable
className="w-full focus-visible:outline-none focus-visible:ring-0 bg-background text-base disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Ask, plan, or search your codebase. @mention files to refine your query."
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={onKeyDown}
/>
<div className="ml-auto z-10">
{isRedirecting ? (
<Button
variant="default"
disabled={true}
size="icon"
className="w-6 h-6"
>
<Loader2 className="w-4 h-4 animate-spin" />
</Button>
) :
isGenerating ? (
<Button
variant="default"
size="sm"
className="h-8"
onClick={onStop}
>
<StopCircleIcon className="w-4 h-4" />
Stop
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => {
// @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();
}
}}
>
<Button
variant={isSubmitDisabled ? "outline" : "default"}
size="sm"
className="w-6 h-6"
onClick={onSubmit}
disabled={isSubmitDisabled}
>
<ArrowUp className="w-4 h-4" />
</Button>
</div>
</TooltipTrigger>
{(isSubmitDisabled && isSubmitDisabledReason === "no-repos-selected") && (
<TooltipContent>
<div className="flex flex-row items-center">
<TriangleAlertIcon className="h-4 w-4 text-warning mr-1" />
<span className="text-destructive">One or more repositories must be selected.</span>
</div>
</TooltipContent>
)}
</Tooltip>
)}
</div>
{suggestionMode !== "none" && (
<SuggestionBox
ref={suggestionsBoxRef}
selectedIndex={index}
onInsertSuggestion={onInsertSuggestion}
isLoading={isLoading}
suggestions={suggestions}
/>
)}
</div>
)
}
const DefaultElement = (props: RenderElementProps) => {
return <p {...props.attributes}>{props.children}</p>
}
const Leaf = (props: RenderLeafProps) => {
return (
<span
{...props.attributes}
>
{props.children}
</span>
)
}
const MentionComponent = ({
attributes,
children,
element: { data },
}: RenderElementPropsFor<MentionElement>) => {
const selected = useSelected();
const focused = useFocused();
if (data.type === 'file') {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
{...attributes}
contentEditable={false}
className={cn(
"px-1.5 py-0.5 mr-1.5 mb-1 align-baseline inline-block rounded bg-muted text-xs font-mono",
{
"ring-2 ring-blue-300": selected && focused
}
)}
>
<span contentEditable={false} className="flex flex-row items-center select-none">
{/* @see: https://github.com/ianstormtaylor/slate/issues/3490 */}
{IS_MAC ? (
<Fragment>
{children}
<VscodeFileIcon fileName={data.name} className="w-3 h-3 mr-1" />
{data.name}
</Fragment>
) : (
<Fragment>
<VscodeFileIcon fileName={data.name} className="w-3 h-3 mr-1" />
{data.name}
{children}
</Fragment>
)}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-xs font-mono">
<span className="font-medium">{data.repo.split('/').pop()}</span>/{data.path}
</span>
</TooltipContent>
</Tooltip>
)
}
}

View file

@ -0,0 +1,113 @@
'use client';
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LanguageModelInfo } from "@/features/chat/types";
import { RepositoryQuery } from "@/lib/types";
import { AtSignIcon } from "lucide-react";
import { useCallback } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { ReactEditor, useSlate } from "slate-react";
import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
import { LanguageModelSelector } from "./languageModelSelector";
import { RepoSelector } from "./repoSelector";
export interface ChatBoxToolbarProps {
languageModels: LanguageModelInfo[];
repos: RepositoryQuery[];
selectedRepos: string[];
onSelectedReposChange: (repos: string[]) => void;
isRepoSelectorOpen: boolean;
onRepoSelectorOpenChanged: (isOpen: boolean) => void;
}
export const ChatBoxToolbar = ({
languageModels,
repos,
selectedRepos,
onSelectedReposChange,
isRepoSelectorOpen,
onRepoSelectorOpenChanged,
}: ChatBoxToolbarProps) => {
const editor = useSlate();
const onAddContext = useCallback(() => {
editor.insertText("@");
ReactEditor.focus(editor);
}, [editor]);
useHotkeys("alt+mod+p", (e) => {
e.preventDefault();
onAddContext();
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Add context",
});
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
});
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-6 h-6 text-muted-foreground hover:text-primary"
onClick={onAddContext}
>
<AtSignIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="flex flex-row items-center gap-2"
>
<KeyboardShortcutHint shortcut="⌥ ⌘ P" />
<Separator orientation="vertical" className="h-4" />
<span>Add context</span>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-3 mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<RepoSelector
className="bg-inherit w-fit h-6 min-h-6"
repos={repos.map((repo) => repo.repoName)}
selectedRepos={selectedRepos}
onSelectedReposChange={onSelectedReposChange}
isOpen={isRepoSelectorOpen}
onOpenChanged={onRepoSelectorOpenChanged}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Repositories to scope conversation to.</span>
</TooltipContent>
</Tooltip>
{languageModels.length > 0 && (
<>
<Separator orientation="vertical" className="h-3 ml-1 mr-2" />
<Tooltip>
<TooltipTrigger asChild>
<div>
<LanguageModelSelector
languageModels={languageModels}
onSelectedModelChange={setSelectedLanguageModel}
selectedModel={selectedLanguageModel}
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Selected language model</span>
</TooltipContent>
</Tooltip>
</>
)}
</>
)
}

View file

@ -0,0 +1 @@
export { ChatBox } from "./chatBox";

View file

@ -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<HTMLInputElement>
) => {
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 (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<PopoverTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
className
)}
>
<div className="flex items-center justify-between mx-auto max-w-64 overflow-hidden">
{selectedModel ? (
<ModelProviderLogo
provider={selectedModel.provider}
className="mr-1"
/>
) : (
<Bot className="h-4 w-4 text-muted-foreground mr-1" />
)}
<span
className={cn(
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap",
selectedModel ? "font-medium" : "font-normal"
)}
>
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search models..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{languageModels
.map((model, index) => {
const isSelected = selectedModel?.model === model.model;
return (
<CommandItem
key={`${model.model}-${index}`}
onSelect={() => {
selectModel(model)
}}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<ModelProviderLogo
provider={model.provider}
className="mr-2"
/>
<span>{model.displayName ?? model.model}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,63 @@
'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 bedrockLogo from "@/public/bedrock.svg";
import geminiLogo from "@/public/gemini.svg";
import openaiLogo from "@/public/openai.svg";
interface ModelProviderLogoProps {
provider: LanguageModelProvider;
className?: string;
}
export const ModelProviderLogo = ({
provider,
className,
}: ModelProviderLogoProps) => {
const { src, className: logoClassName } = useMemo(() => {
switch (provider) {
case 'amazon-bedrock':
return {
src: bedrockLogo,
className: 'w-3.5 h-3.5 dark:invert'
};
case 'anthropic':
return {
src: anthropicLogo,
className: 'dark:invert'
};
case 'openai':
return {
src: openaiLogo,
className: 'dark:invert w-3.5 h-3.5'
};
case 'google-generative-ai':
case 'google-vertex':
return {
src: geminiLogo,
className: 'w-3.5 h-3.5'
};
case 'google-vertex-anthropic':
return {
src: anthropicLogo,
className: 'dark:invert'
};
}
}, [provider]);
return (
<Image
src={src}
alt={provider}
className={cn(
'w-4 h-4',
logoClassName,
className
)}
/>
)
}

View file

@ -0,0 +1,165 @@
// Adapted from: web/src/components/ui/multi-select.tsx
import * as React from "react";
import {
CheckIcon,
ChevronDown,
BookMarkedIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
interface RepoSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
repos: string[];
selectedRepos: string[];
onSelectedReposChange: (repos: string[]) => void;
className?: string;
isOpen: boolean;
onOpenChanged: (isOpen: boolean) => void;
}
export const RepoSelector = React.forwardRef<
HTMLButtonElement,
RepoSelectorProps
>(
(
{
repos,
onSelectedReposChange,
className,
selectedRepos,
isOpen,
onOpenChanged,
...props
},
ref
) => {
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
onOpenChanged(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedRepos = [...selectedRepos];
newSelectedRepos.pop();
onSelectedReposChange(newSelectedRepos);
}
};
const toggleRepo = (repo: string) => {
const newSelectedValues = selectedRepos.includes(repo)
? selectedRepos.filter((value) => value !== repo)
: [...selectedRepos, repo];
onSelectedReposChange(newSelectedValues);
};
const handleClear = () => {
onSelectedReposChange([]);
};
const handleTogglePopover = () => {
onOpenChanged(!isOpen);
};
return (
<Popover
open={isOpen}
onOpenChange={onOpenChanged}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
className
)}
>
<div className="flex items-center justify-between w-full mx-auto">
<BookMarkedIcon className="h-4 w-4 text-muted-foreground mr-1" />
<span
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
>
{
selectedRepos.length === 0 ? `Select a repo` :
selectedRepos.length === 1 ? `${selectedRepos[0].split('/').pop()}` :
`${selectedRepos.length} repo${selectedRepos.length === 1 ? '' : 's'}`
}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => onOpenChanged(false)}
>
<Command>
<CommandInput
placeholder="Search repos..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{repos.map((repo) => {
const isSelected = selectedRepos.includes(repo);
return (
<CommandItem
key={repo}
onSelect={() => toggleRepo(repo)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>{repo}</span>
</CommandItem>
);
})}
</CommandGroup>
{selectedRepos.length > 0 && (
<>
<CommandSeparator />
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
);
RepoSelector.displayName = "RepoSelector";

View file

@ -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<HTMLDivElement, SuggestionBoxProps>(({
selectedIndex,
onInsertSuggestion,
isLoading,
suggestions,
}, ref) => {
return createPortal(
<div
ref={ref}
className="absolute z-10 top-0 left-0 bg-background border rounded-md p-1 w-[500px] overflow-hidden text-ellipsis"
data-cy="mentions-portal"
>
{isLoading ? (
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5 w-full">
{
Array.from({ length: 10 }).map((_, index) => (
<Skeleton key={index} className="h-4 w-full" />
))
}
</div>
) :
(suggestions.length === 0) ? (
<div className="flex flex-col gap-2 px-1 py-0.5 w-full">
<p className="text-sm text-muted-foreground">
No results found
</p>
</div>
) :
(
<div className="flex flex-col w-full">
{suggestions.map((suggestion, i) => (
<div
key={i}
className={cn("flex flex-row gap-2 w-full cursor-pointer rounded-md px-1 py-0.5 hover:bg-accent", {
"bg-accent": i === selectedIndex,
})}
onClick={() => {
onInsertSuggestion(suggestion);
}}
>
{
suggestion.type === 'file' && (
<FileSuggestionListItem file={suggestion} />
)
}
{
suggestion.type === 'refine' && (
<RefineSuggestionListItem refine={suggestion} />
)
}
</div>
))}
</div>
)}
</div>,
document.body
)
});
SuggestionBox.displayName = 'SuggestionBox';
const FileSuggestionListItem = ({ file }: { file: FileSuggestion }) => {
return (
<>
<VscodeFileIcon fileName={file.name} className="mt-1" />
<div className="flex flex-col w-full">
<span className="text-sm font-medium">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
<span className="font-medium">{file.repo.split('/').pop()}</span>/{file.path}
</span>
</div>
</>
)
}
const RefineSuggestionListItem = ({ refine }: { refine: RefineSuggestion }) => {
const Icon = useMemo(() => {
switch (refine.targetSuggestionMode) {
case 'file':
return VscFiles;
}
}, [refine.targetSuggestionMode]);
return (
<>
<Icon className="w-4 h-4 flex-shrink-0 mt-1" />
<div className="flex flex-col w-full">
<span className="text-sm font-medium">
{refine.name}
</span>
<span className="text-xs text-muted-foreground">
{refine.description}
</span>
</div>
</>
)
}

View file

@ -0,0 +1,24 @@
export type SuggestionMode =
"none" |
"refine" |
"file"
;
export type RefineSuggestion = {
type: 'refine';
targetSuggestionMode: Exclude<SuggestionMode, 'none' | 'refine'>;
name: string;
description: string;
}
export type FileSuggestion = {
type: 'file';
repo: string;
path: string;
name: string;
language: string;
revision: string;
}
export type Suggestion = FileSuggestion | RefineSuggestion;

View file

@ -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,
}
}

View file

@ -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: [],
}
}
}

View file

@ -0,0 +1,183 @@
'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;
feedback?: 'like' | 'dislike' | undefined;
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<HTMLDivElement, AnswerCardProps>(({
answerText,
messageId,
chatId,
feedback: _feedback,
traceId,
}, forwardedRef) => {
const markdownRendererRef = useRef<HTMLDivElement>(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>(_feedback);
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 (
<div className="flex flex-row w-full relative scroll-mt-16">
{(isTOCButtonToggled && tocItems.length > 0) && (
<TableOfContents
tocItems={tocItems}
activeId={activeId}
className="sticky top-0 h-fit max-w-44 py-2 mr-1.5"
/>
)}
<div className="flex flex-col w-full bg-[#fcfcfc] dark:bg-[#0e1320] px-4 py-2 rounded-lg shadow-sm">
<div className="flex flex-col z-10 bg-inherit py-2 sticky top-0">
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-muted-foreground">Answer</p>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CopyIconButton
onCopy={onCopyAnswer}
className="h-6 w-6 text-muted-foreground"
/>
</TooltipTrigger>
<TooltipContent
side="bottom"
>
Copy answer
</TooltipContent>
</Tooltip>
{tocItems.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Toggle
className="h-6 w-6 px-3 min-w-6 text-muted-foreground"
pressed={isTOCButtonToggled}
onPressedChange={setIsTOCButtonToggled}
>
<TableOfContentsIcon className="h-3 w-3" />
</Toggle>
</TooltipTrigger>
<TooltipContent
side="bottom"
>
Toggle table of contents
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<Separator />
</div>
<MarkdownRenderer
ref={markdownRendererRef}
content={answerText}
// scroll-mt offsets the scroll position for headings to take account
// of the sticky "answer" header.
className="prose prose-sm max-w-none prose-headings:scroll-mt-14"
/>
<Separator className="my-2" />
<div className="flex gap-2">
<Button
variant={feedback === 'like' ? "default" : "ghost"}
size="sm"
className="h-8 px-2"
onClick={() => onFeedback('like')}
disabled={isSubmittingFeedback || feedback !== undefined}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant={feedback === 'dislike' ? "default" : "ghost"}
size="sm"
className="h-8 px-2"
onClick={() => onFeedback('dislike')}
disabled={isSubmittingFeedback || feedback !== undefined}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})
AnswerCard.displayName = 'AnswerCard';

View file

@ -0,0 +1,337 @@
'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, 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 } from '@/lib/types';
type ChatHistoryState = {
scrollOffset?: number;
}
interface ChatThreadProps {
id?: string | undefined;
initialMessages?: SBChatMessage[];
inputMessage?: CreateUIMessage<SBChatMessage>;
languageModels: LanguageModelInfo[];
repos: RepositoryQuery[];
selectedRepos: string[];
onSelectedReposChange: (repos: string[]) => void;
isChatReadonly: boolean;
}
export const ChatThread = ({
id: defaultChatId,
initialMessages,
inputMessage,
languageModels,
repos,
selectedRepos,
onSelectedReposChange,
isChatReadonly,
}: ChatThreadProps) => {
const domain = useDomain();
const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const latestMessagePairRef = useRef<HTMLDivElement>(null);
const hasSubmittedInputMessage = useRef(false);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false);
const { toast } = useToast();
const router = useRouter();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false);
// Initial state is from attachments that exist in in the chat history.
const [sources, setSources] = useState<Source[]>(
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<SBChatMessage>({
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<SBChatMessage>) => {
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: {
selectedRepos,
languageModelId: selectedLanguageModel.model,
} satisfies AdditionalChatRequestParams,
});
}, [_sendMessage, selectedLanguageModel, selectedRepos, toast]);
const messagePairs = useMessagePairs(messages);
useNavigationGuard({
enabled: 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), selectedRepos);
sendMessage(message);
setIsAutoScrollEnabled(true);
resetEditor(editor);
}, [sendMessage, selectedRepos]);
return (
<>
{error && (
<ErrorBanner
error={error}
isVisible={isErrorBannerVisible}
onClose={() => setIsErrorBannerVisible(false)}
/>
)}
<ScrollArea
ref={scrollAreaRef}
className="flex flex-col h-full w-full p-4 overflow-hidden"
>
{
messagePairs.length === 0 ? (
<div className="flex items-center justify-center text-center h-full">
<p className="text-muted-foreground">no messages</p>
</div>
) : (
<>
{messagePairs.map(([userMessage, assistantMessage], index) => {
const isLastPair = index === messagePairs.length - 1;
const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
return (
<Fragment key={index}>
<ChatThreadListItem
chatId={chatId}
userMessage={userMessage}
assistantMessage={assistantMessage}
isStreaming={isStreaming}
sources={sources}
ref={isLastPair ? latestMessagePairRef : undefined}
/>
{index !== messagePairs.length - 1 && (
<Separator className="my-12" />
)}
</Fragment>
);
})}
</>
)
}
{
(!isAutoScrollEnabled && status === "streaming") && (
<div className="absolute bottom-5 left-0 right-0 h-10 flex flex-row items-center justify-center">
<Button
variant="outline"
size="icon"
className="rounded-full animate-bounce-slow h-8 w-8"
onClick={() => {
latestMessagePairRef.current?.scrollIntoView({
behavior: 'instant',
block: 'end',
inline: 'nearest',
});
}}
>
<ArrowDownIcon className="w-4 h-4" />
</Button>
</div>
)
}
</ScrollArea>
{!isChatReadonly && (
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
<CustomSlateEditor>
<ChatBox
onSubmit={onSubmit}
className="min-h-[80px]"
preferredSuggestionsBoxPlacement="top-start"
isGenerating={status === "streaming" || status === "submitted"}
onStop={stop}
languageModels={languageModels}
selectedRepos={selectedRepos}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
languageModels={languageModels}
repos={repos}
selectedRepos={selectedRepos}
onSelectedReposChange={onSelectedReposChange}
isRepoSelectorOpen={isRepoSelectorOpen}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen}
/>
</div>
</CustomSlateEditor>
</div>
)}
</>
);
}

View file

@ -0,0 +1,462 @@
'use client';
import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { Brain, CheckCircle, ChevronDown, ChevronRight, Clock, Cpu, InfoIcon, Loader2, Zap } from 'lucide-react';
import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import scrollIntoView from 'scroll-into-view-if-needed';
import { ANSWER_TAG } from '../../constants';
import { Reference, referenceSchema, SBChatMessage, SBChatMessageMetadata, Source } from "../../types";
import { useExtractReferences } from '../../useExtractReferences';
import { getAnswerPartFromAssistantMessage, groupMessageIntoSteps } from '../../utils';
import { AnswerCard } from './answerCard';
import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer';
import { ReferencedSourcesListView } from './referencedSourcesListView';
import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent';
import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent';
import { ReadFilesToolComponent } from './tools/readFilesToolComponent';
import { SearchCodeToolComponent } from './tools/searchCodeToolComponent';
interface ChatThreadListItemProps {
userMessage: SBChatMessage;
assistantMessage?: SBChatMessage;
isStreaming: boolean;
sources: Source[];
chatId: string;
}
export const ChatThreadListItem = forwardRef<HTMLDivElement, ChatThreadListItemProps>(({
userMessage,
assistantMessage,
isStreaming,
sources,
chatId,
}, ref) => {
const leftPanelRef = useRef<HTMLDivElement>(null);
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
const markdownRendererRef = useRef<HTMLDivElement>(null);
const [hoveredReference, setHoveredReference] = useState<Reference | undefined>(undefined);
const [selectedReference, setSelectedReference] = useState<Reference | undefined>(undefined);
const references = useExtractReferences(assistantMessage);
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]);
const messageMetadata = useMemo((): SBChatMessageMetadata | undefined => {
return assistantMessage?.metadata;
}, [assistantMessage?.metadata]);
const answerPart = useMemo(() => {
if (!assistantMessage) {
return undefined;
}
return getAnswerPartFromAssistantMessage(assistantMessage, isStreaming);
}, [assistantMessage, isStreaming]);
const thinkingSteps = useMemo(() => {
const steps = groupMessageIntoSteps(assistantMessage?.parts ?? []);
// Filter out the answerPart and empty steps
return steps.map(step => step.filter(part => part !== answerPart)).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]);
useEffect(() => {
if (!markdownRendererRef.current) {
return;
}
const markdownRenderer = markdownRendererRef.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
useEffect(() => {
if (!selectedReference) {
return;
}
const referenceElement = document.getElementById(`user-content-${selectedReference.id}`);
if (!referenceElement) {
return;
}
scrollIntoView(referenceElement, {
behavior: 'smooth',
scrollMode: 'if-needed',
block: 'center',
});
referenceElement.classList.add('chat-reference--selected');
return () => {
referenceElement.classList.remove('chat-reference--selected');
};
}, [selectedReference]);
useEffect(() => {
if (!hoveredReference) {
return;
}
const referenceElement = document.getElementById(`user-content-${hoveredReference.id}`);
if (!referenceElement) {
return;
}
referenceElement.classList.add('chat-reference--hover');
return () => {
referenceElement.classList.remove('chat-reference--hover');
};
}, [hoveredReference]);
return (
<div
className="flex flex-col md:flex-row relative min-h-[calc(100vh-250px)]"
ref={ref}
>
<ResizablePanelGroup
direction="horizontal"
style={{
height: 'auto',
overflow: 'visible',
}}
>
<ResizablePanel
order={1}
minSize={30}
maxSize={70}
defaultSize={50}
style={{
overflow: 'visible',
}}
>
<div
ref={leftPanelRef}
className="py-4 h-full"
>
<div className="flex flex-row gap-2 mb-4">
{isStreaming ? (
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0 mt-1.5" />
) : (
<CheckCircle className="w-4 h-4 text-green-700 flex-shrink-0 mt-1.5" />
)}
<MarkdownRenderer
content={userQuestion.trim()}
className="prose-p:m-0"
/>
</div>
{isThinking && (
<div className="space-y-4 mb-4">
<Skeleton className="h-4 max-w-32" />
<div className="space-y-2">
<Skeleton className="h-3 max-w-72" />
<Skeleton className="h-3 max-w-64" />
<Skeleton className="h-3 max-w-56" />
</div>
</div>
)}
<Card className="mb-4">
<Collapsible open={isDetailsPanelExpanded} onOpenChange={onExpandDetailsPanel}>
<CollapsibleTrigger asChild>
<CardContent
className={cn("p-3 cursor-pointer hover:bg-muted", {
"rounded-lg": !isDetailsPanelExpanded,
"rounded-t-lg": isDetailsPanelExpanded,
})}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-4">
<p className="flex items-center font-semibold text-muted-foreground text-sm">
{isThinking ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-1 flex-shrink-0" />
Thinking...
</>
) : (
<>
<InfoIcon className="w-4 h-4 mr-1 flex-shrink-0" />
Details
</>
)}
</p>
{!isStreaming && (
<>
<Separator orientation="vertical" className="h-4" />
{messageMetadata?.modelName && (
<div className="flex items-center text-xs">
<Cpu className="w-3 h-3 mr-1 flex-shrink-0" />
{messageMetadata?.modelName}
</div>
)}
{messageMetadata?.totalTokens && (
<div className="flex items-center text-xs">
<Zap className="w-3 h-3 mr-1 flex-shrink-0" />
{messageMetadata?.totalTokens} tokens
</div>
)}
{messageMetadata?.totalResponseTimeMs && (
<div className="flex items-center text-xs">
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
{messageMetadata?.totalResponseTimeMs / 1000} seconds
</div>
)}
<div className="flex items-center text-xs">
<Brain className="w-3 h-3 mr-1 flex-shrink-0" />
{`${thinkingSteps.length} step${thinkingSteps.length === 1 ? '' : 's'}`}
</div>
</>
)}
</div>
{isDetailsPanelExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
</CardContent>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="mt-2 space-y-6">
{thinkingSteps.length === 0 ? (
isStreaming ? (
<Skeleton className="h-24 w-full" />
) : (
<p className="text-sm text-muted-foreground">No thinking steps</p>
)
) : thinkingSteps.map((step, index) => {
return (
<div
key={index}
className="border-l-2 pl-4 relative border-muted"
>
<div
className={`absolute left-[-9px] top-1 w-4 h-4 rounded-full flex items-center justify-center bg-muted`}
>
<span
className={`text-xs font-semibold`}
>
{index + 1}
</span>
</div>
{step.map((part, index) => {
switch (part.type) {
case 'reasoning':
case 'text':
return (
<MarkdownRenderer
key={index}
content={part.text}
className="text-sm"
/>
)
case 'tool-readFiles':
return (
<ReadFilesToolComponent
key={index}
part={part}
/>
)
case 'tool-searchCode':
return (
<SearchCodeToolComponent
key={index}
part={part}
/>
)
case 'tool-findSymbolDefinitions':
return (
<FindSymbolDefinitionsToolComponent
key={index}
part={part}
/>
)
case 'tool-findSymbolReferences':
return (
<FindSymbolReferencesToolComponent
key={index}
part={part}
/>
)
default:
return null;
}
})}
</div>
)
})}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
{/* Answer section */}
{(answerPart && assistantMessage) ? (
<AnswerCard
ref={markdownRendererRef}
answerText={answerPart.text.replace(ANSWER_TAG, '').trim()}
chatId={chatId}
messageId={assistantMessage.id}
feedback={messageMetadata?.feedback?.type}
traceId={messageMetadata?.traceId}
/>
) : !isStreaming && (
<p className="text-destructive">Error: No answer response was provided</p>
)}
</div>
</ResizablePanel>
<AnimatedResizableHandle className='mx-4' />
<ResizablePanel
order={2}
minSize={30}
maxSize={70}
defaultSize={50}
style={{
overflow: 'visible',
position: 'relative',
}}
>
<div
className="sticky top-0"
>
{references.length > 0 ? (
<ReferencedSourcesListView
references={references}
sources={sources}
hoveredReference={hoveredReference}
selectedReference={selectedReference}
onSelectedReferenceChanged={setSelectedReference}
onHoveredReferenceChanged={setHoveredReference}
style={rightPanelStyle}
/>
) : isStreaming ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="w-full h-48" />
))}
</div>
) : (
<div className="p-4 text-center text-muted-foreground text-sm">
No file references found
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
});
ChatThreadListItem.displayName = 'ChatThreadListItem';

View file

@ -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 (
<div className="flex flex-col rounded-md border overflow-hidden not-prose my-4">
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
{
"max-h-[350px]": !isExpanded && isExpandButtonVisible, // Roughly 14 lines
"max-h-none": isExpanded || !isExpandButtonVisible
}
)}
>
<LightweightCodeHighlighter
language={language}
lineNumbers={true}
renderWhitespace={true}
>
{code}
</LightweightCodeHighlighter>
</div>
{isExpandButtonVisible && (
<div
tabIndex={0}
className="flex flex-row items-center justify-center w-full bg-accent py-1 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key !== "Enter") {
return;
}
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
<span className="text-sm ml-1">{isExpanded ? 'Show less' : 'Show more'}</span>
</div>
)}
</div>
);
};

View file

@ -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) => (
<div
key={index}
className="py-[3px] px-1.5 bg-chat-reference hover:bg-chat-reference-hover cursor-pointer" onClick={() => onClick(direction)}
>
{direction === 'up' && <PiArrowLineUp className="w-4 h-4" />}
{direction === 'down' && <PiArrowLineDown className="w-4 h-4" />}
{direction === 'merged' && <PiArrowsOutLineVertical className="w-4 h-4" />}
</div>
))}
</>
)
}

Some files were not shown because too many files have changed in this diff Show more