Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Added pagination and UTC time range filtering to the audit GET endpoint. [#949](https://github.com/sourcebot-dev/sourcebot/pull/949)
- Added AI Search Assist — describe what you're looking for in natural language and AI will generate a code search query for you. [#951](https://github.com/sourcebot-dev/sourcebot/pull/951)

### Fixed
- Fixed search query parser rejecting parenthesized regex alternation in filter values (e.g. `file:(test|spec)`, `-file:(test|spec)`). [#946](https://github.com/sourcebot-dev/sourcebot/pull/946)
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"pages": [
"docs/features/search/overview",
"docs/features/search/syntax-reference",
"docs/features/search/ai-search-assist",
"docs/features/search/multi-branch-indexing",
"docs/features/search/search-contexts"
]
Expand Down
26 changes: 26 additions & 0 deletions docs/docs/features/search/ai-search-assist.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
title: AI Search Assist
---

AI Search Assist lets you describe what you're looking for in natural language and automatically generates a search query for you.

<video
autoPlay
muted
loop
playsInline
className="w-full aspect-video"
src="/images/search-assist.mp4"
></video>


## How it works

1. Click the wand icon (<Icon icon="wand-sparkles" />) in the search bar to open the AI Search Assist panel.
2. Describe what you're looking for in natural language.
3. Click **Generate** or press <kbd>↵</kbd> to generate the query.
4. The generated query is inserted into the search bar.

## Requirements

AI Search Assist requires a language model to be configured. See [Configure Language Models](/docs/configuration/language-model-providers) for setup instructions.
Binary file added docs/images/search-assist.mp4
Binary file not shown.
3 changes: 2 additions & 1 deletion packages/queryLanguage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ type Tree = ReturnType<typeof parser.parse>;
type SyntaxNode = Tree['topNode'];
export type { Tree, SyntaxNode };
export * from "./parser";
export * from "./parser.terms";
export * from "./parser.terms";
export { SEARCH_SYNTAX_DESCRIPTION } from "./syntaxDescription";
99 changes: 99 additions & 0 deletions packages/queryLanguage/src/syntaxDescription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* LLM-readable description of the Sourcebot search query syntax.
* Keep this in sync with query.grammar and tokens.ts when the syntax changes.
*/
export const SEARCH_SYNTAX_DESCRIPTION = String.raw`
# Sourcebot Search Query Syntax

## Search terms

Bare words search across file content and are interpreted as case-sensitive regular expressions:
useState — matches files containing "useState"
useS?tate — matches files containing "useState" or "usetate"
^import — matches lines beginning with "import"
error.*handler — matches "error" followed by "handler" on the same line

Wrap terms in double quotes to match a phrase with spaces:
"password reset" — matches files containing the phrase "password reset"

## Filters

Narrow searches with prefix:value syntax:

file:<value> — filter by file path
lang:<value> — filter by language. Uses linguist language definitions (e.g. TypeScript, Python, Go, Rust, Java)
repo:<value> — filter by repository name
sym:<value> — filter by symbol name
rev:<value> — filter by git branch or tag

All filter values are interpreted as case-sensitive regular expressions.
A plain word matches as a substring. No forward slashes around values.

## Boolean logic

Space = AND. All space-separated terms must match.
useState lang:TypeScript — TypeScript files containing useState

or = OR (must be lowercase, not at start/end of query).
auth or login — files containing "auth" or "login"

- = negation. Only valid before a filter or a parenthesized group.
-file:test — exclude paths matching /test/
-(file:test or file:spec) — exclude test and spec files

## Grouping

Parentheses group expressions:
(auth or login) lang:TypeScript
-(file:test or file:spec)

## Quoting

Wrap a value in double quotes when it contains spaces:
"password reset"
"error handler"

When the quoted value itself contains double-quote characters, escape each one as \":
"\"key\": \"value\"" — matches the literal text: "key": "value"

For unquoted values, escape regex metacharacters with a single backslash:
file:package\.json — matches literal "package.json"

## Examples

Input: find all TODO comments
Output: //\s*TODO

Input: find TypeScript files that use useState
Output: lang:TypeScript useState

Input: find files that import from react
Output: lang:TypeScript "from \"react\""

Input: find all test files
Output: file:(test|spec)

Input: find all API route handlers
Output: file:route\.(ts|js)$

Input: find package.json files that depend on react
Output: file:package\.json "\"react\": \""

Input: find package.json files with beta or alpha dependencies
Output: file:package\.json "\"[^\"]+\": \"[^\"]*-(beta|alpha)"

Input: find package.json files where next is pinned to version 15
Output: file:package\.json "\"next\": \"\\^?15\\."

Input: find next versions less than 15
Output: file:package\.json "\"next\": \"\^?(1[0-4]|[1-9])\."

Input: find log4j versions 2.3.x or lower
Output: file:package\.json "\"log4j\": \"\^?2\.([0-2]|3)\."

Input: find TypeScript files that import from react or react-dom
Output: lang:TypeScript "from \"(react|react-dom)\""

Input: find files with password reset logic, excluding tests
Output: "password reset" -file:test
`.trim();
4 changes: 3 additions & 1 deletion packages/web/src/app/[domain]/browse/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auth } from "@/auth";
import { LayoutClient } from "./layoutClient";
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";

interface LayoutProps {
children: React.ReactNode;
Expand All @@ -9,8 +10,9 @@ export default async function Layout({
children,
}: LayoutProps) {
const session = await auth();
const languageModels = await getConfiguredLanguageModelsInfo();
return (
<LayoutClient session={session}>
<LayoutClient session={session} isSearchAssistSupported={languageModels.length > 0}>
{children}
</LayoutClient>
)
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/app/[domain]/browse/layoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { Session } from "next-auth";
interface LayoutProps {
children: React.ReactNode;
session: Session | null;
isSearchAssistSupported: boolean;
}

export function LayoutClient({
children,
session,
isSearchAssistSupported,
}: LayoutProps) {
const { repoName, revisionName } = useBrowseParams();
const domain = useDomain();
Expand All @@ -38,6 +40,7 @@ export function LayoutClient({
query: `repo:^${escapeStringRegexp(repoName)}$${revisionName ? ` rev:${revisionName}` : ''} `,
}}
className="w-full"
isSearchAssistSupported={isSearchAssistSupported}
/>
</TopBar>
<ResizablePanelGroup
Expand Down
165 changes: 165 additions & 0 deletions packages/web/src/app/[domain]/components/searchBar/searchAssistBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'use client';

import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useCallback, useRef, useState } from "react";
import { useToast } from "@/components/hooks/use-toast";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { translateSearchQuery } from "@/features/searchAssist/actions";
import { isServiceError } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { Loader2, WandSparkles, Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import Link from "next/link";

const SEARCH_ASSIST_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/ai-search-assist";

const EXAMPLES = [
"Find console.log statements",
"Find todo comments",
"Find hardcoded API keys or secrets",
];

interface SearchAssistBoxProps {
isEnabled: boolean;
onBlur: () => void;
onQueryGenerated: (query: string) => void;
className?: string;
}

export const SearchAssistBox = ({
isEnabled,
onBlur,
onQueryGenerated,
className,
}: SearchAssistBoxProps) => {
const [query, setQuery] = useState("");
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const captureEvent = useCaptureEvent();

const translateQuery = useCallback(async () => {
if (!query.trim() || isLoading) {
return;
}

setIsLoading(true);
try {
const result = await translateSearchQuery({ prompt: query });
if (isServiceError(result)) {
toast({ title: "Failed to generate query", description: result.message ?? "An unexpected error occurred.", variant: "destructive" });
captureEvent('wa_search_assist_generate_failed', {});
return;
}
onQueryGenerated(result.query);
captureEvent('wa_search_assist_query_generated', {});
setQuery("");
} catch {
toast({ title: "Failed to generate query", description: "An unexpected error occurred.", variant: "destructive" });
captureEvent('wa_search_assist_generate_failed', {});
} finally {
setIsLoading(false);
}
}, [query, isLoading, toast, onQueryGenerated, captureEvent]);

const onExampleClicked = useCallback((example: string) => {
setQuery(example);
inputRef.current?.focus();
captureEvent('wa_search_assist_example_clicked', { example });
}, [captureEvent]);

if (!isEnabled) {
return null;
}

return (
<div
ref={boxRef}
className={cn("absolute z-10 left-16 right-0 max-w-[600px] border rounded-md bg-background drop-shadow-2xl p-2", className)}
tabIndex={0}
onBlur={(e) => {
// Don't close if focus is moving to another element within this box
if (boxRef.current?.contains(e.relatedTarget as Node)) {
return;
}

onBlur();
}}
>
<div className="flex flex-row items-center gap-1.5 mb-2">
<p className="text-muted-foreground text-sm">Generate a query</p>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 text-muted-foreground cursor-pointer" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[260px] flex flex-col gap-2 p-3">
<p className="text-sm">Describe what you&apos;re looking for in natural language and AI will generate a search query for you.</p>
<Link
href={SEARCH_ASSIST_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:underline"
>
Learn more
</Link>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-row gap-2 items-center">
<div className="relative flex-1">
<Input
placeholder="Describe what you're looking for..."
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
translateQuery();
}
}}
disabled={isLoading}
autoFocus
className="focus-visible:ring-0 focus-visible:ring-offset-0 h-9 pr-12"
/>
{!isLoading && query.trim() && (
<div className="absolute right-2 inset-y-0 flex items-center pointer-events-none">
<kbd className="text-sm text-muted-foreground border rounded px-1 py-0.5">↵</kbd>
</div>
)}
</div>
<Button
size="sm"
onClick={translateQuery}
disabled={isLoading || !query.trim()}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<WandSparkles className="w-4 h-4" />
)}
Generate
</Button>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{EXAMPLES.map((example) => (
<button
key={example}
onClick={() => onExampleClicked(example)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onExampleClicked(example);
}
}}
className="text-xs text-muted-foreground border rounded-full px-2.5 py-1 hover:bg-muted hover:text-foreground transition-colors"
>
{example}
</button>
))}
</div>
</div>
)
}
Loading