Skip to content

Commit 1db9ed6

Browse files
feat(web): AI Search Assist (#951)
* further wip * improved prompt * docs * changelog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): add AI Search Assist news entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * amend news entry * changelog * generalize examples --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e369199 commit 1db9ed6

File tree

17 files changed

+500
-69
lines changed

17 files changed

+500
-69
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added pagination and UTC time range filtering to the audit GET endpoint. [#949](https://github.com/sourcebot-dev/sourcebot/pull/949)
12+
- 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)
1213

1314
### Fixed
1415
- 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)

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"pages": [
4040
"docs/features/search/overview",
4141
"docs/features/search/syntax-reference",
42+
"docs/features/search/ai-search-assist",
4243
"docs/features/search/multi-branch-indexing",
4344
"docs/features/search/search-contexts"
4445
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: AI Search Assist
3+
---
4+
5+
AI Search Assist lets you describe what you're looking for in natural language and automatically generates a search query for you.
6+
7+
<video
8+
autoPlay
9+
muted
10+
loop
11+
playsInline
12+
className="w-full aspect-video"
13+
src="/images/search-assist.mp4"
14+
></video>
15+
16+
17+
## How it works
18+
19+
1. Click the wand icon (<Icon icon="wand-sparkles" />) in the search bar to open the AI Search Assist panel.
20+
2. Describe what you're looking for in natural language.
21+
3. Click **Generate** or press <kbd>↵</kbd> to generate the query.
22+
4. The generated query is inserted into the search bar.
23+
24+
## Requirements
25+
26+
AI Search Assist requires a language model to be configured. See [Configure Language Models](/docs/configuration/language-model-providers) for setup instructions.

docs/images/search-assist.mp4

2.04 MB
Binary file not shown.

packages/queryLanguage/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ type Tree = ReturnType<typeof parser.parse>;
44
type SyntaxNode = Tree['topNode'];
55
export type { Tree, SyntaxNode };
66
export * from "./parser";
7-
export * from "./parser.terms";
7+
export * from "./parser.terms";
8+
export { SEARCH_SYNTAX_DESCRIPTION } from "./syntaxDescription";
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* LLM-readable description of the Sourcebot search query syntax.
3+
* Keep this in sync with query.grammar and tokens.ts when the syntax changes.
4+
*/
5+
export const SEARCH_SYNTAX_DESCRIPTION = String.raw`
6+
# Sourcebot Search Query Syntax
7+
8+
## Search terms
9+
10+
Bare words search across file content and are interpreted as case-sensitive regular expressions:
11+
useState — matches files containing "useState"
12+
useS?tate — matches files containing "useState" or "usetate"
13+
^import — matches lines beginning with "import"
14+
error.*handler — matches "error" followed by "handler" on the same line
15+
16+
Wrap terms in double quotes to match a phrase with spaces:
17+
"password reset" — matches files containing the phrase "password reset"
18+
19+
## Filters
20+
21+
Narrow searches with prefix:value syntax:
22+
23+
file:<value> — filter by file path
24+
lang:<value> — filter by language. Uses linguist language definitions (e.g. TypeScript, Python, Go, Rust, Java)
25+
repo:<value> — filter by repository name
26+
sym:<value> — filter by symbol name
27+
rev:<value> — filter by git branch or tag
28+
29+
All filter values are interpreted as case-sensitive regular expressions.
30+
A plain word matches as a substring. No forward slashes around values.
31+
32+
## Boolean logic
33+
34+
Space = AND. All space-separated terms must match.
35+
useState lang:TypeScript — TypeScript files containing useState
36+
37+
or = OR (must be lowercase, not at start/end of query).
38+
auth or login — files containing "auth" or "login"
39+
40+
- = negation. Only valid before a filter or a parenthesized group.
41+
-file:test — exclude paths matching /test/
42+
-(file:test or file:spec) — exclude test and spec files
43+
44+
## Grouping
45+
46+
Parentheses group expressions:
47+
(auth or login) lang:TypeScript
48+
-(file:test or file:spec)
49+
50+
## Quoting
51+
52+
Wrap a value in double quotes when it contains spaces:
53+
"password reset"
54+
"error handler"
55+
56+
When the quoted value itself contains double-quote characters, escape each one as \":
57+
"\"key\": \"value\"" — matches the literal text: "key": "value"
58+
59+
For unquoted values, escape regex metacharacters with a single backslash:
60+
file:package\.json — matches literal "package.json"
61+
62+
## Examples
63+
64+
Input: find all TODO comments
65+
Output: //\s*TODO
66+
67+
Input: find TypeScript files that use useState
68+
Output: lang:TypeScript useState
69+
70+
Input: find files that import from react
71+
Output: lang:TypeScript "from \"react\""
72+
73+
Input: find all test files
74+
Output: file:(test|spec)
75+
76+
Input: find all API route handlers
77+
Output: file:route\.(ts|js)$
78+
79+
Input: find package.json files that depend on react
80+
Output: file:package\.json "\"react\": \""
81+
82+
Input: find package.json files with beta or alpha dependencies
83+
Output: file:package\.json "\"[^\"]+\": \"[^\"]*-(beta|alpha)"
84+
85+
Input: find package.json files where next is pinned to version 15
86+
Output: file:package\.json "\"next\": \"\\^?15\\."
87+
88+
Input: find next versions less than 15
89+
Output: file:package\.json "\"next\": \"\^?(1[0-4]|[1-9])\."
90+
91+
Input: find log4j versions 2.3.x or lower
92+
Output: file:package\.json "\"log4j\": \"\^?2\.([0-2]|3)\."
93+
94+
Input: find TypeScript files that import from react or react-dom
95+
Output: lang:TypeScript "from \"(react|react-dom)\""
96+
97+
Input: find files with password reset logic, excluding tests
98+
Output: "password reset" -file:test
99+
`.trim();

packages/web/src/app/[domain]/browse/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { auth } from "@/auth";
22
import { LayoutClient } from "./layoutClient";
3+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
34

45
interface LayoutProps {
56
children: React.ReactNode;
@@ -9,8 +10,9 @@ export default async function Layout({
910
children,
1011
}: LayoutProps) {
1112
const session = await auth();
13+
const languageModels = await getConfiguredLanguageModelsInfo();
1214
return (
13-
<LayoutClient session={session}>
15+
<LayoutClient session={session} isSearchAssistSupported={languageModels.length > 0}>
1416
{children}
1517
</LayoutClient>
1618
)

packages/web/src/app/[domain]/browse/layoutClient.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import { Session } from "next-auth";
1616
interface LayoutProps {
1717
children: React.ReactNode;
1818
session: Session | null;
19+
isSearchAssistSupported: boolean;
1920
}
2021

2122
export function LayoutClient({
2223
children,
2324
session,
25+
isSearchAssistSupported,
2426
}: LayoutProps) {
2527
const { repoName, revisionName } = useBrowseParams();
2628
const domain = useDomain();
@@ -38,6 +40,7 @@ export function LayoutClient({
3840
query: `repo:^${escapeStringRegexp(repoName)}$${revisionName ? ` rev:${revisionName}` : ''} `,
3941
}}
4042
className="w-full"
43+
isSearchAssistSupported={isSearchAssistSupported}
4144
/>
4245
</TopBar>
4346
<ResizablePanelGroup
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
'use client';
2+
3+
import { Input } from "@/components/ui/input";
4+
import { Button } from "@/components/ui/button";
5+
import { useCallback, useRef, useState } from "react";
6+
import { useToast } from "@/components/hooks/use-toast";
7+
import useCaptureEvent from "@/hooks/useCaptureEvent";
8+
import { translateSearchQuery } from "@/features/searchAssist/actions";
9+
import { isServiceError } from "@/lib/utils";
10+
import { cn } from "@/lib/utils";
11+
import { Loader2, WandSparkles, Info } from "lucide-react";
12+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
13+
import Link from "next/link";
14+
15+
const SEARCH_ASSIST_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/ai-search-assist";
16+
17+
const EXAMPLES = [
18+
"Find console.log statements",
19+
"Find todo comments",
20+
"Find hardcoded API keys or secrets",
21+
];
22+
23+
interface SearchAssistBoxProps {
24+
isEnabled: boolean;
25+
onBlur: () => void;
26+
onQueryGenerated: (query: string) => void;
27+
className?: string;
28+
}
29+
30+
export const SearchAssistBox = ({
31+
isEnabled,
32+
onBlur,
33+
onQueryGenerated,
34+
className,
35+
}: SearchAssistBoxProps) => {
36+
const [query, setQuery] = useState("");
37+
const boxRef = useRef<HTMLDivElement>(null);
38+
const inputRef = useRef<HTMLInputElement>(null);
39+
const [isLoading, setIsLoading] = useState(false);
40+
const { toast } = useToast();
41+
const captureEvent = useCaptureEvent();
42+
43+
const translateQuery = useCallback(async () => {
44+
if (!query.trim() || isLoading) {
45+
return;
46+
}
47+
48+
setIsLoading(true);
49+
try {
50+
const result = await translateSearchQuery({ prompt: query });
51+
if (isServiceError(result)) {
52+
toast({ title: "Failed to generate query", description: result.message ?? "An unexpected error occurred.", variant: "destructive" });
53+
captureEvent('wa_search_assist_generate_failed', {});
54+
return;
55+
}
56+
onQueryGenerated(result.query);
57+
captureEvent('wa_search_assist_query_generated', {});
58+
setQuery("");
59+
} catch {
60+
toast({ title: "Failed to generate query", description: "An unexpected error occurred.", variant: "destructive" });
61+
captureEvent('wa_search_assist_generate_failed', {});
62+
} finally {
63+
setIsLoading(false);
64+
}
65+
}, [query, isLoading, toast, onQueryGenerated, captureEvent]);
66+
67+
const onExampleClicked = useCallback((example: string) => {
68+
setQuery(example);
69+
inputRef.current?.focus();
70+
captureEvent('wa_search_assist_example_clicked', { example });
71+
}, [captureEvent]);
72+
73+
if (!isEnabled) {
74+
return null;
75+
}
76+
77+
return (
78+
<div
79+
ref={boxRef}
80+
className={cn("absolute z-10 left-16 right-0 max-w-[600px] border rounded-md bg-background drop-shadow-2xl p-2", className)}
81+
tabIndex={0}
82+
onBlur={(e) => {
83+
// Don't close if focus is moving to another element within this box
84+
if (boxRef.current?.contains(e.relatedTarget as Node)) {
85+
return;
86+
}
87+
88+
onBlur();
89+
}}
90+
>
91+
<div className="flex flex-row items-center gap-1.5 mb-2">
92+
<p className="text-muted-foreground text-sm">Generate a query</p>
93+
<Tooltip>
94+
<TooltipTrigger asChild>
95+
<Info className="w-3.5 h-3.5 text-muted-foreground cursor-pointer" />
96+
</TooltipTrigger>
97+
<TooltipContent side="right" className="max-w-[260px] flex flex-col gap-2 p-3">
98+
<p className="text-sm">Describe what you&apos;re looking for in natural language and AI will generate a search query for you.</p>
99+
<Link
100+
href={SEARCH_ASSIST_DOCS_URL}
101+
target="_blank"
102+
rel="noopener noreferrer"
103+
className="text-sm text-blue-500 hover:underline"
104+
>
105+
Learn more
106+
</Link>
107+
</TooltipContent>
108+
</Tooltip>
109+
</div>
110+
<div className="flex flex-row gap-2 items-center">
111+
<div className="relative flex-1">
112+
<Input
113+
placeholder="Describe what you're looking for..."
114+
ref={inputRef}
115+
value={query}
116+
onChange={(e) => setQuery(e.target.value)}
117+
onKeyDown={(e) => {
118+
if (e.key === 'Enter') {
119+
e.preventDefault();
120+
translateQuery();
121+
}
122+
}}
123+
disabled={isLoading}
124+
autoFocus
125+
className="focus-visible:ring-0 focus-visible:ring-offset-0 h-9 pr-12"
126+
/>
127+
{!isLoading && query.trim() && (
128+
<div className="absolute right-2 inset-y-0 flex items-center pointer-events-none">
129+
<kbd className="text-sm text-muted-foreground border rounded px-1 py-0.5"></kbd>
130+
</div>
131+
)}
132+
</div>
133+
<Button
134+
size="sm"
135+
onClick={translateQuery}
136+
disabled={isLoading || !query.trim()}
137+
>
138+
{isLoading ? (
139+
<Loader2 className="w-4 h-4 animate-spin" />
140+
) : (
141+
<WandSparkles className="w-4 h-4" />
142+
)}
143+
Generate
144+
</Button>
145+
</div>
146+
<div className="flex flex-wrap gap-1.5 mt-2">
147+
{EXAMPLES.map((example) => (
148+
<button
149+
key={example}
150+
onClick={() => onExampleClicked(example)}
151+
onKeyDown={(e) => {
152+
if (e.key === 'Enter') {
153+
e.preventDefault();
154+
onExampleClicked(example);
155+
}
156+
}}
157+
className="text-xs text-muted-foreground border rounded-full px-2.5 py-1 hover:bg-muted hover:text-foreground transition-colors"
158+
>
159+
{example}
160+
</button>
161+
))}
162+
</div>
163+
</div>
164+
)
165+
}

0 commit comments

Comments
 (0)