diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index b23f3ae2a..17a9adf92 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -40,6 +40,7 @@ import { getAuditService } from "@/ee/features/audit/factory"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { getOrgMetadata } from "@/lib/utils"; import { getOrgFromDomain } from "./data/org"; +import { searchModeSchema } from "@/types"; const ajv = new Ajv({ validateFormats: false, @@ -2161,6 +2162,100 @@ export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean }); } +export const getDefaultSearchMode = async (domain: string): Promise<"precise" | "agentic" | ServiceError> => sew(async () => { + const org = await getOrgFromDomain(domain); + if (!org) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Organization not found", + } satisfies ServiceError; + } + + // If no metadata is set, return default (precise) + if (org.metadata === null) { + return "precise"; + } + + const orgMetadata = getOrgMetadata(org); + if (!orgMetadata) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INVALID_ORG_METADATA, + message: "Invalid organization metadata", + } satisfies ServiceError; + } + + return orgMetadata.defaultSearchMode ?? "precise"; +}); + +export const setDefaultSearchMode = async (domain: string, mode: "precise" | "agentic"): Promise<{ success: boolean } | ServiceError> => sew(async () => { + // Runtime validation to guard server action from invalid input + const parsed = searchModeSchema.safeParse(mode); + if (!parsed.success) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Invalid default search mode", + } satisfies ServiceError; + } + const validatedMode = parsed.data; + + return await withAuth(async (userId) => { + return await withOrgMembership(userId, domain, async ({ org }) => { + // Validate that agentic mode is not being set when no language models are configured + if (validatedMode === "agentic") { + const { getConfiguredLanguageModelsInfo } = await import("@/features/chat/actions"); + const languageModels = await getConfiguredLanguageModelsInfo(); + if (languageModels.length === 0) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Cannot set Ask mode as default when no language models are configured", + } satisfies ServiceError; + } + } + + const currentMetadata = getOrgMetadata(org); + const previousMode = currentMetadata?.defaultSearchMode ?? "precise"; + const mergedMetadata = { + ...(currentMetadata ?? {}), + defaultSearchMode: validatedMode, + }; + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + metadata: mergedMetadata, + }, + }); + + await auditService.createAudit({ + action: "org.settings.default_search_mode_updated", + actor: { + id: userId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + previousDefaultSearchMode: previousMode, + newDefaultSearchMode: validatedMode + } + }); + + return { + success: true, + }; + }, /* minRequiredRole = */ OrgRole.OWNER); + }); +}); + ////// Helpers /////// const parseConnectionConfig = (config: string) => { @@ -2266,4 +2361,4 @@ export const encryptValue = async (value: string) => { export const decryptValue = async (iv: string, encryptedValue: string) => { return decrypt(iv, encryptedValue); -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 09bf65a9f..27c79cab9 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -1,4 +1,4 @@ -import { getRepos, getSearchContexts } from "@/actions"; +import { getDefaultSearchMode, getRepos, getSearchContexts } from "@/actions"; import { Footer } from "@/app/components/footer"; import { getOrgFromDomain } from "@/data/org"; import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions"; @@ -48,14 +48,21 @@ export default async function Home(props: { params: Promise<{ domain: string }> const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); - // Read search mode from cookie, defaulting to agentic if not set - // (assuming a language model is configured). + // Get org's default search mode + const defaultSearchMode = await getDefaultSearchMode(domain); + // If there was an error or no setting found, default to precise (search) + const orgDefaultMode = isServiceError(defaultSearchMode) ? "precise" : defaultSearchMode; + const effectiveOrgDefaultMode = + orgDefaultMode === "agentic" && models.length === 0 ? "precise" : orgDefaultMode; + + // Read search mode from cookie, defaulting to the org's default setting if not set const cookieStore = await cookies(); const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME); const initialSearchMode = ( searchModeCookie?.value === "agentic" || searchModeCookie?.value === "precise" - ) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise"; + ) ? ((searchModeCookie.value === "agentic" && models.length === 0) ? "precise" : searchModeCookie.value) + : effectiveOrgDefaultMode; const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true"; diff --git a/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx new file mode 100644 index 000000000..1a0af564c --- /dev/null +++ b/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { setDefaultSearchMode } from "@/actions"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { OrgRole } from "@sourcebot/db"; +import { MessageCircleIcon, SearchIcon } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { useToast } from "@/components/hooks/use-toast"; + +interface DefaultSearchModeCardProps { + initialDefaultMode: "precise" | "agentic"; + currentUserRole: OrgRole; + isAskModeAvailable: boolean; +} + +export const DefaultSearchModeCard = ({ initialDefaultMode, currentUserRole, isAskModeAvailable }: DefaultSearchModeCardProps) => { + const { domain } = useParams<{ domain: string }>(); + // If Ask mode is not available and the initial mode is agentic, force it to precise + const effectiveInitialMode = !isAskModeAvailable && initialDefaultMode === "agentic" ? "precise" : initialDefaultMode; + const [defaultSearchMode, setDefaultSearchModeState] = useState<"precise" | "agentic">(effectiveInitialMode); + const [isUpdating, setIsUpdating] = useState(false); + const isReadOnly = currentUserRole !== OrgRole.OWNER; + const { toast } = useToast(); + + const handleUpdateDefaultSearchMode = async () => { + if (isReadOnly) { + return; + } + + setIsUpdating(true); + try { + const result = await setDefaultSearchMode(domain as string, defaultSearchMode); + if (!result || typeof result !== 'object') { + throw new Error('Unexpected response'); + } + // If this is a ServiceError, surface its message + if ('statusCode' in result && 'errorCode' in result && 'message' in result) { + toast({ + title: "Failed to update", + description: result.message, + variant: "destructive", + }); + return; + } + if (!result.success) { + throw new Error('Failed to update default search mode'); + } + toast({ + title: "Default search mode updated", + description: `Default search mode has been set to ${defaultSearchMode === "agentic" ? "Ask" : "Code Search"}.`, + variant: "success", + }); + } catch (error) { + console.error('Error updating default search mode:', error); + // If we already showed a specific error above, do nothing here; otherwise fallback + if (!(error instanceof Error && /Unexpected response/.test(error.message))) { + toast({ + title: "Failed to update", + description: "An error occurred while updating the default search mode.", + variant: "destructive", + }); + } + } finally { + setIsUpdating(false); + } + }; + + return ( + + + Default Search Mode + + Choose which search mode will be the default when users first visit Sourcebot + {!isAskModeAvailable && ( + + Ask mode is unavailable (no language models configured) + + )} + + + + + + + + Update + + + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index c52f1b550..ff2fd678c 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -1,11 +1,14 @@ import { ChangeOrgNameCard } from "./components/changeOrgNameCard"; import { isServiceError } from "@/lib/utils"; -import { getCurrentUserRole } from "@/actions"; +import { getCurrentUserRole, getDefaultSearchMode } from "@/actions"; import { getOrgFromDomain } from "@/data/org"; import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; +import { DefaultSearchModeCard } from "./components/defaultSearchModeCard"; import { ServiceErrorException } from "@/lib/serviceError"; import { ErrorCode } from "@/lib/errorCodes"; import { headers } from "next/headers"; +import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; +import { OrgRole } from "@sourcebot/db"; interface GeneralSettingsPageProps { params: Promise<{ @@ -36,6 +39,14 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp const host = (await headers()).get('host') ?? ''; + // Get the default search mode setting + const defaultSearchMode = await getDefaultSearchMode(domain); + const initialDefaultMode = isServiceError(defaultSearchMode) ? "precise" : defaultSearchMode; + + // Get available language models to determine if "Ask" mode is available + const languageModels = await getConfiguredLanguageModelsInfo(); + const isAskModeAvailable = languageModels.length > 0; + return (
@@ -52,6 +63,14 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp currentUserRole={currentUserRole} rootDomain={host} /> + + {currentUserRole === OrgRole.OWNER && ( + + )}
) } diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 2ceb5d30e..dea7d3635 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +export const searchModeSchema = z.enum(["precise", "agentic"]); + export const orgMetadataSchema = z.object({ anonymousAccessEnabled: z.boolean().optional(), + defaultSearchMode: searchModeSchema.optional(), }) export const demoSearchScopeSchema = z.object({