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 packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
export {
base64Decode,
loadConfig,
loadJsonFile,
isRemotePath,
} from "./utils.js";
export {
Expand Down
61 changes: 61 additions & 0 deletions packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <T>(
filePath: string,
schema: any
): Promise<T> => {
Comment thread
msukkari marked this conversation as resolved.
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<SourcebotConfig> => {
const configContent = await (async () => {
if (isRemotePath(configPath)) {
Expand Down
248 changes: 18 additions & 230 deletions packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<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;

import { DemoExamples } from "@/types";
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";

interface AgenticSearchProps {
searchModeSelectorProps: SearchModeSelectorProps;
Expand All @@ -116,49 +23,23 @@ interface AgenticSearchProps {
createdAt: Date;
name: string | null;
}[];
demoExamples: DemoExamples | undefined;
}

export const AgenticSearch = ({
searchModeSelectorProps,
languageModels,
repos,
searchContexts,
chatHistory,
demoExamples,
}: AgenticSearchProps) => {
const [selectedSuggestionType, _setSelectedSuggestionType] = useState<SuggestionType | undefined>(undefined);
const { createNewChatThread, isLoading } = useCreateNewChatThread();
const dropdownRef = useRef<HTMLDivElement>(null);
const editor = useSlate();
const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("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 (
<div className="flex flex-col items-center w-full max-w-[800px]">
<div
className="mt-4 w-full border rounded-md shadow-sm"
>
<div className="flex flex-col items-center w-full">
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
<ChatBox
onSubmit={(children) => {
createNewChatThread(children, selectedItems);
Expand Down Expand Up @@ -187,111 +68,18 @@ export const AgenticSearch = ({
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) {
setIsContextSelectorOpen(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>
{demoExamples && (
<AskSourcebotDemoCards
demoExamples={demoExamples}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
searchContexts={searchContexts}
repos={repos}
/>
)}
</div >
)
}
}
Loading