diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 3cc4be65..ce00642e 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -12,6 +12,7 @@ export type { export { base64Decode, loadConfig, + loadJsonFile, isRemotePath, } from "./utils.js"; export { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 431ff741..69cbc52e 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -3,6 +3,7 @@ import { indexSchema } from "@sourcebot/schemas/v3/index.schema"; import { readFile } from 'fs/promises'; import stripJsonComments from 'strip-json-comments'; import { Ajv } from "ajv"; +import { z } from "zod"; const ajv = new Ajv({ validateFormats: false, @@ -18,6 +19,66 @@ export const isRemotePath = (path: string) => { return path.startsWith('https://') || path.startsWith('http://'); } +// TODO: Merge this with config loading logic which uses AJV +export const loadJsonFile = async ( + filePath: string, + schema: any +): Promise => { + const fileContent = await (async () => { + if (isRemotePath(filePath)) { + const response = await fetch(filePath); + if (!response.ok) { + throw new Error(`Failed to fetch file ${filePath}: ${response.statusText}`); + } + return response.text(); + } else { + // Retry logic for handling race conditions with mounted volumes + const maxAttempts = 5; + const retryDelayMs = 2000; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await readFile(filePath, { + encoding: 'utf-8', + }); + } catch (error) { + lastError = error as Error; + + // Only retry on ENOENT errors (file not found) + if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw error; // Throw immediately for non-ENOENT errors + } + + // Log warning before retry (except on the last attempt) + if (attempt < maxAttempts) { + console.warn(`File not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + } + } + + // If we've exhausted all retries, throw the last ENOENT error + if (lastError) { + throw lastError; + } + + throw new Error('Failed to load file after all retry attempts'); + } + })(); + + const parsedData = JSON.parse(stripJsonComments(fileContent)); + + try { + return schema.parse(parsedData); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error(`File '${filePath}' is invalid: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`); + } + throw error; + } +} + export const loadConfig = async (configPath: string): Promise => { const configContent = await (async () => { if (isRemotePath(configPath)) { diff --git a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx index cbd6999b..c8bd472f 100644 --- a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx +++ b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx @@ -1,110 +1,17 @@ '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, SearchContextQuery } 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 { useState } from "react"; import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar"; import { useLocalStorage } from "usehooks-ts"; import { ContextItem } from "@/features/chat/components/chatBox/contextSelector"; - -// @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 = { - 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 ( - - {children} - - ) -} - -const suggestions: Record = { - 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: Summarize the purpose of this file @file: - }, - { - 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; - +import { DemoExamples } from "@/types"; +import { AskSourcebotDemoCards } from "./askSourcebotDemoCards"; interface AgenticSearchProps { searchModeSelectorProps: SearchModeSelectorProps; @@ -116,6 +23,7 @@ interface AgenticSearchProps { createdAt: Date; name: string | null; }[]; + demoExamples: DemoExamples | undefined; } export const AgenticSearch = ({ @@ -123,42 +31,15 @@ export const AgenticSearch = ({ languageModels, repos, searchContexts, - chatHistory, + demoExamples, }: AgenticSearchProps) => { - const [selectedSuggestionType, _setSelectedSuggestionType] = useState(undefined); const { createNewChatThread, isLoading } = useCreateNewChatThread(); - const dropdownRef = useRef(null); - const editor = useSlate(); const [selectedItems, setSelectedItems] = useLocalStorage("selectedContextItems", [], { initializeWithValue: false }); - const domain = useDomain(); const [isContextSelectorOpen, setIsContextSelectorOpen] = 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 ( -
-
+
+
{ createNewChatThread(children, selectedItems); @@ -187,111 +68,18 @@ export const AgenticSearch = ({ className="ml-auto" />
- - {selectedSuggestionType && ( -
-

- {suggestionTypes[selectedSuggestionType].title} -

- {suggestions[selectedSuggestionType].map(({ queryText, queryNode, openRepoSelector }, index) => ( -
{ - resetEditor(editor); - editor.insertText(queryText); - setSelectedSuggestionType(undefined); - - if (openRepoSelector) { - setIsContextSelectorOpen(true); - } else { - ReactEditor.focus(editor); - } - }} - > - - {queryNode ?? queryText} -
- ))} -
- )}
-
-
- {Object.entries(suggestionTypes).map(([type, suggestion], index) => ( - { - setSelectedSuggestionType(type as SuggestionType); - }} - /> - ))} -
-
- {chatHistory.length > 0 && ( -
- - Recent conversations -
- {chatHistory - .slice(0, MAX_RECENT_CHAT_HISTORY_COUNT) - .map((chat) => ( - - - {chat.name ?? "Untitled Chat"} - - - {getDisplayTime(chat.createdAt)} - - - ))} -
- {chatHistory.length > MAX_RECENT_CHAT_HISTORY_COUNT && ( - - View all - - )} -
+ + {demoExamples && ( + )} -
+ ) -} - - -interface ExampleButtonProps { - Icon: LucideIcon; - title: string; - onClick: () => void; -} - -const ExampleButton = ({ - Icon, - title, - onClick, -}: ExampleButtonProps) => { - return ( - - ) -} +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx b/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx new file mode 100644 index 00000000..532c9e06 --- /dev/null +++ b/packages/web/src/app/[domain]/components/homepage/askSourcebotDemoCards.tsx @@ -0,0 +1,211 @@ +'use client'; + +import Image from "next/image"; +import { Search, LibraryBigIcon, Code, Layers } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { CardContent } from "@/components/ui/card"; +import { ContextItem, RepoContextItem, SearchContextItem } from "@/features/chat/components/chatBox/contextSelector"; +import { DemoExamples, DemoSearchExample, DemoSearchContextExample, DemoSearchContext } from "@/types"; +import { cn, getCodeHostIcon } from "@/lib/utils"; +import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; + +interface AskSourcebotDemoCardsProps { + demoExamples: DemoExamples; + selectedItems: ContextItem[]; + setSelectedItems: (items: ContextItem[]) => void; + searchContexts: SearchContextQuery[]; + repos: RepositoryQuery[]; +} + +export const AskSourcebotDemoCards = ({ + demoExamples, + selectedItems, + setSelectedItems, + searchContexts, + repos, +}: AskSourcebotDemoCardsProps) => { + const handleExampleClick = (example: DemoSearchExample) => { + if (example.url) { + window.open(example.url, '_blank'); + } + } + + const getContextIcon = (context: DemoSearchContext, size: number = 20) => { + const sizeClass = size === 12 ? "h-3 w-3" : "h-5 w-5"; + + if (context.type === "set") { + return ; + } + + if (context.codeHostType) { + const codeHostIcon = getCodeHostIcon(context.codeHostType); + if (codeHostIcon) { + return ( + {`${context.codeHostType} + ); + } + } + + return ; + } + + const handleContextClick = (demoSearchContexts: DemoSearchContext[], contextExample: DemoSearchContextExample) => { + const context = demoSearchContexts.find((context) => context.id === contextExample.searchContext) + if (!context) { + console.error(`Search context ${contextExample.searchContext} not found on handleContextClick`); + return; + } + + if (context.type === "set") { + const searchContext = searchContexts.find((item) => item.name === context.value); + if (!searchContext) { + console.error(`Search context ${context.value} not found on handleContextClick`); + return; + } + + const isSelected = selectedItems.some( + (selected) => selected.type === 'context' && selected.value === context.value + ); + const newSelectedItems = isSelected + ? selectedItems.filter( + (selected) => !(selected.type === 'context' && selected.value === context.value) + ) + : [...selectedItems, { type: 'context', value: context.value, name: context.displayName, repoCount: searchContext.repoNames.length } as SearchContextItem]; + + setSelectedItems(newSelectedItems); + } else { + const repo = repos.find((repo) => repo.repoName === context.value); + if (!repo) { + console.error(`Repo ${context.value} not found on handleContextClick`); + return; + } + + const isSelected = selectedItems.some( + (selected) => selected.type === 'repo' && selected.value === context.value + ); + const newSelectedItems = isSelected + ? selectedItems.filter( + (selected) => !(selected.type === 'repo' && selected.value === context.value) + ) + : [...selectedItems, { type: 'repo', value: context.value, name: context.displayName, codeHostType: repo.codeHostType } as RepoContextItem]; + + setSelectedItems(newSelectedItems); + } + } + + return ( +
+ {/* Search Context Row */} +
+
+
+ +

Search Context

+
+

Select the context you want to ask questions about

+
+
+ {demoExamples.searchContextExamples.map((contextExample) => { + const context = demoExamples.searchContexts.find((context) => context.id === contextExample.searchContext) + if (!context) { + console.error(`Search context ${contextExample.searchContext} not found on handleContextClick`); + return null; + } + + const isSelected = selectedItems.some( + (selected) => (selected.type === 'context' && selected.value === context.value) || + (selected.type === 'repo' && selected.value === context.value) + ); + + const searchContext = searchContexts.find((item) => item.name === context.value); + const numRepos = searchContext ? searchContext.repoNames.length : undefined; + return ( + handleContextClick(demoExamples.searchContexts, contextExample)} + > + +
+
+ {getContextIcon(context)} +
+
+
+

+ {context.displayName} +

+ {numRepos && ( + + {numRepos} repos + + )} +
+

{contextExample.description}

+
+
+
+
+ ) + })} +
+
+ + {/* Example Searches Row */} +
+
+
+ +

Community Ask Results

+
+

Check out these featured ask results from the community

+
+
+ {demoExamples.searchExamples.map((example) => { + const searchContexts = demoExamples.searchContexts.filter((context) => example.searchContext.includes(context.id)) + return ( + handleExampleClick(example)} + > + +
+
+ {searchContexts.map((context) => ( + + {getContextIcon(context, 12)} + {context.displayName} + + ))} +
+
+

+ {example.title} +

+

+ {example.description} +

+
+
+
+
+ )})} +
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/homepage/index.tsx b/packages/web/src/app/[domain]/components/homepage/index.tsx index c4014ac3..47262e0e 100644 --- a/packages/web/src/app/[domain]/components/homepage/index.tsx +++ b/packages/web/src/app/[domain]/components/homepage/index.tsx @@ -10,6 +10,7 @@ import { SearchMode } from "./toolbar"; import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { setSearchModeCookie } from "@/actions"; import { useCallback, useState } from "react"; +import { DemoExamples } from "@/types"; interface HomepageProps { initialRepos: RepositoryQuery[]; @@ -21,6 +22,7 @@ interface HomepageProps { name: string | null; }[]; initialSearchMode: SearchMode; + demoExamples: DemoExamples | undefined; } @@ -30,6 +32,7 @@ export const Homepage = ({ languageModels, chatHistory, initialSearchMode, + demoExamples, }: HomepageProps) => { const [searchMode, setSearchMode] = useState(initialSearchMode); const isAgenticSearchEnabled = languageModels.length > 0; @@ -86,6 +89,7 @@ export const Homepage = ({ repos={initialRepos} searchContexts={searchContexts} chatHistory={chatHistory} + demoExamples={demoExamples} /> )} diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index a4455f34..34ee3bed 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -11,6 +11,9 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { auth } from "@/auth"; import { cookies } from "next/headers"; import { SEARCH_MODE_COOKIE_NAME } from "@/lib/constants"; +import { env } from "@/env.mjs"; +import { loadJsonFile } from "@sourcebot/shared"; +import { DemoExamples, demoExamplesSchema } from "@/types"; export default async function Home({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); @@ -48,6 +51,15 @@ export default async function Home({ params: { domain } }: { params: { domain: s searchModeCookie?.value === "precise" ) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise"; + const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => { + try { + return await loadJsonFile(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema); + } catch (error) { + console.error('Failed to load demo examples:', error); + return undefined; + } + })() : undefined; + return (
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 0059b791..6dcfaef0 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -129,6 +129,8 @@ export const env = createEnv({ DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'), LANGFUSE_SECRET_KEY: z.string().optional(), + + SOURCEBOT_DEMO_EXAMPLES_PATH: z.string().optional(), }, // @NOTE: Please make sure of the following: // - Make sure you destructure all client variables in diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 35bb8301..c33cbdf1 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -162,7 +162,7 @@ export const ChatBox = ({ if (isSubmitDisabled) { if (isSubmitDisabledReason === "no-repos-selected") { toast({ - description: "⚠️ One or more repositories or search contexts must be selected.", + description: "⚠️ You must select at least one search context", variant: "destructive", }); onContextSelectorOpenChanged(true); @@ -284,7 +284,7 @@ export const ChatBox = ({ >
- One or more repositories or search contexts must be selected. + You must select at least one search context
)} diff --git a/packages/web/src/features/chat/components/chatBox/contextSelector.tsx b/packages/web/src/features/chat/components/chatBox/contextSelector.tsx index e2ebe18c..3bec39c5 100644 --- a/packages/web/src/features/chat/components/chatBox/contextSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/contextSelector.tsx @@ -194,7 +194,7 @@ export const ContextSelector = React.forwardRef< > diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 394c3835..5b55b9db 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -4,4 +4,34 @@ export const orgMetadataSchema = z.object({ anonymousAccessEnabled: z.boolean().optional(), }) -export type OrgMetadata = z.infer; \ No newline at end of file +export const demoSearchContextSchema = z.object({ + id: z.number(), + displayName: z.string(), + value: z.string(), + type: z.enum(["repo", "set"]), + codeHostType: z.string().optional(), +}) + +export const demoSearchExampleSchema = z.object({ + title: z.string(), + description: z.string(), + url: z.string(), + searchContext: z.array(z.number()) +}) + +export const demoSearchContextExampleSchema = z.object({ + searchContext: z.number(), + description: z.string(), +}) + +export const demoExamplesSchema = z.object({ + searchContexts: demoSearchContextSchema.array(), + searchExamples: demoSearchExampleSchema.array(), + searchContextExamples: demoSearchContextExampleSchema.array(), +}) + +export type OrgMetadata = z.infer; +export type DemoExamples = z.infer; +export type DemoSearchContext = z.infer; +export type DemoSearchExample = z.infer; +export type DemoSearchContextExample = z.infer; \ No newline at end of file