Skip to content
Open
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
97 changes: 96 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -2266,4 +2361,4 @@ export const encryptValue = async (value: string) => {

export const decryptValue = async (iv: string, encryptedValue: string) => {
return decrypt(iv, encryptedValue);
}
}
15 changes: 11 additions & 4 deletions packages/web/src/app/[domain]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Default Search Mode</CardTitle>
<CardDescription>
Choose which search mode will be the default when users first visit Sourcebot
{!isAskModeAvailable && (
<span className="block text-yellow-600 dark:text-yellow-400 mt-1">
Ask mode is unavailable (no language models configured)
</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={defaultSearchMode}
onValueChange={(value) => setDefaultSearchModeState(value as "precise" | "agentic")}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="Select default search mode">
{defaultSearchMode === "precise" ? "Code Search" : defaultSearchMode === "agentic" ? "Ask" : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="precise">Code Search</SelectItem>
<SelectItem value="agentic" disabled={!isAskModeAvailable}>
Ask {!isAskModeAvailable && "(unavailable)"}
</SelectItem>
</SelectContent>
</Select>
</CardContent>
<CardFooter>
<LoadingButton
onClick={handleUpdateDefaultSearchMode}
loading={isUpdating}
disabled={isReadOnly || isUpdating || defaultSearchMode === effectiveInitialMode}
>
Update
</LoadingButton>
</CardFooter>
</Card>
);
};
21 changes: 20 additions & 1 deletion packages/web/src/app/[domain]/settings/(general)/page.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-6">
<div>
Expand All @@ -52,6 +63,14 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp
currentUserRole={currentUserRole}
rootDomain={host}
/>

{currentUserRole === OrgRole.OWNER && (
<DefaultSearchModeCard
initialDefaultMode={initialDefaultMode}
currentUserRole={currentUserRole}
isAskModeAvailable={isAskModeAvailable}
/>
)}
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/types.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down