Skip to content

Commit f330360

Browse files
committed
feat(web): Add configurable default search mode setting
Resolves #471 by allowing organizations to set their default search mode to "Code Search" instead of "Ask" to prevent accidental token consumption. - Add defaultSearchMode field to orgMetadata schema with "precise" | "agentic" enum - Implement getDefaultSearchMode and setDefaultSearchMode server actions with owner-only permissions - Create DefaultSearchModeCard component with validation for language model availability - Integrate org default with cookie-based user preference fallback chain - Add audit logging for setting changes and proper error handling
1 parent ca9069e commit f330360

File tree

5 files changed

+208
-6
lines changed

5 files changed

+208
-6
lines changed

packages/web/src/actions.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,87 @@ export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean
21612161
});
21622162
}
21632163

2164+
export const getDefaultSearchMode = async (domain: string): Promise<"precise" | "agentic" | ServiceError> => sew(async () => {
2165+
const org = await getOrgFromDomain(domain);
2166+
if (!org) {
2167+
return {
2168+
statusCode: StatusCodes.NOT_FOUND,
2169+
errorCode: ErrorCode.NOT_FOUND,
2170+
message: "Organization not found",
2171+
} satisfies ServiceError;
2172+
}
2173+
2174+
// If no metadata is set, return default (precise)
2175+
if (org.metadata === null) {
2176+
return "precise";
2177+
}
2178+
2179+
const orgMetadata = getOrgMetadata(org);
2180+
if (!orgMetadata) {
2181+
return {
2182+
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
2183+
errorCode: ErrorCode.INVALID_ORG_METADATA,
2184+
message: "Invalid organization metadata",
2185+
} satisfies ServiceError;
2186+
}
2187+
2188+
return orgMetadata.defaultSearchMode ?? "precise";
2189+
});
2190+
2191+
export const setDefaultSearchMode = async (domain: string, mode: "precise" | "agentic"): Promise<{ success: boolean } | ServiceError> => sew(async () => {
2192+
return await withAuth(async (userId) => {
2193+
return await withOrgMembership(userId, domain, async ({ org }) => {
2194+
// Validate that agentic mode is not being set when no language models are configured
2195+
if (mode === "agentic") {
2196+
const { getConfiguredLanguageModelsInfo } = await import("@/features/chat/actions");
2197+
const languageModels = await getConfiguredLanguageModelsInfo();
2198+
if (languageModels.length === 0) {
2199+
return {
2200+
statusCode: StatusCodes.BAD_REQUEST,
2201+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
2202+
message: "Cannot set Ask mode as default when no language models are configured",
2203+
} satisfies ServiceError;
2204+
}
2205+
}
2206+
2207+
const currentMetadata = getOrgMetadata(org);
2208+
const mergedMetadata = {
2209+
...(currentMetadata ?? {}),
2210+
defaultSearchMode: mode,
2211+
};
2212+
2213+
await prisma.org.update({
2214+
where: {
2215+
id: org.id,
2216+
},
2217+
data: {
2218+
metadata: mergedMetadata,
2219+
},
2220+
});
2221+
2222+
await auditService.createAudit({
2223+
action: "org.settings.default_search_mode_updated",
2224+
actor: {
2225+
id: userId,
2226+
type: "user"
2227+
},
2228+
target: {
2229+
id: org.id.toString(),
2230+
type: "org"
2231+
},
2232+
orgId: org.id,
2233+
metadata: {
2234+
defaultSearchMode: mode
2235+
}
2236+
});
2237+
2238+
return {
2239+
success: true,
2240+
};
2241+
}, /* minRequiredRole = */ OrgRole.OWNER);
2242+
});
2243+
});
2244+
21642245
////// Helpers ///////
21652246

21662247
const parseConnectionConfig = (config: string) => {
@@ -2266,4 +2347,4 @@ export const encryptValue = async (value: string) => {
22662347

22672348
export const decryptValue = async (iv: string, encryptedValue: string) => {
22682349
return decrypt(iv, encryptedValue);
2269-
}
2350+
}

packages/web/src/app/[domain]/page.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getRepos, getSearchContexts } from "@/actions";
1+
import { getDefaultSearchMode, getRepos, getSearchContexts } from "@/actions";
22
import { Footer } from "@/app/components/footer";
33
import { getOrgFromDomain } from "@/data/org";
44
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
@@ -48,14 +48,18 @@ export default async function Home(props: { params: Promise<{ domain: string }>
4848

4949
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
5050

51-
// Read search mode from cookie, defaulting to agentic if not set
52-
// (assuming a language model is configured).
51+
// Get org's default search mode
52+
const defaultSearchMode = await getDefaultSearchMode(domain);
53+
// If there was an error or no setting found, default to precise (search)
54+
const orgDefaultMode = isServiceError(defaultSearchMode) ? "precise" : defaultSearchMode;
55+
56+
// Read search mode from cookie, defaulting to the org's default setting if not set
5357
const cookieStore = await cookies();
5458
const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME);
5559
const initialSearchMode = (
5660
searchModeCookie?.value === "agentic" ||
5761
searchModeCookie?.value === "precise"
58-
) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
62+
) ? searchModeCookie.value : orgDefaultMode;
5963

6064
const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
6165

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use client';
2+
3+
import { setDefaultSearchMode } from "@/actions";
4+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
5+
import { Label } from "@/components/ui/label";
6+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
7+
import { LoadingButton } from "@/components/ui/loading-button";
8+
import { OrgRole } from "@sourcebot/db";
9+
import { MessageCircleIcon, SearchIcon } from "lucide-react";
10+
import { useState } from "react";
11+
import { useParams } from "next/navigation";
12+
import { useToast } from "@/components/hooks/use-toast";
13+
14+
interface DefaultSearchModeCardProps {
15+
initialDefaultMode: "precise" | "agentic";
16+
currentUserRole: OrgRole;
17+
isAskModeAvailable: boolean;
18+
}
19+
20+
export const DefaultSearchModeCard = ({ initialDefaultMode, currentUserRole, isAskModeAvailable }: DefaultSearchModeCardProps) => {
21+
const { domain } = useParams<{ domain: string }>();
22+
// If Ask mode is not available and the initial mode is agentic, force it to precise
23+
const effectiveInitialMode = !isAskModeAvailable && initialDefaultMode === "agentic" ? "precise" : initialDefaultMode;
24+
const [defaultSearchMode, setDefaultSearchModeState] = useState<"precise" | "agentic">(effectiveInitialMode);
25+
const [isUpdating, setIsUpdating] = useState(false);
26+
const isReadOnly = currentUserRole !== OrgRole.OWNER;
27+
const { toast } = useToast();
28+
29+
const handleUpdateDefaultSearchMode = async () => {
30+
if (isReadOnly) {
31+
return;
32+
}
33+
34+
setIsUpdating(true);
35+
try {
36+
const result = await setDefaultSearchMode(domain as string, defaultSearchMode);
37+
if (!result || typeof result !== 'object' || !result.success) {
38+
throw new Error('Failed to update default search mode');
39+
}
40+
toast({
41+
title: "Default search mode updated",
42+
description: `Default search mode has been set to ${defaultSearchMode === "agentic" ? "Ask" : "Code Search"}.`,
43+
variant: "success",
44+
});
45+
} catch (error) {
46+
console.error('Error updating default search mode:', error);
47+
toast({
48+
title: "Failed to update",
49+
description: "An error occurred while updating the default search mode.",
50+
variant: "destructive",
51+
});
52+
} finally {
53+
setIsUpdating(false);
54+
}
55+
};
56+
57+
return (
58+
<Card>
59+
<CardHeader>
60+
<CardTitle>Default Search Mode</CardTitle>
61+
<CardDescription>
62+
Choose which search mode will be the default when users first visit Sourcebot
63+
{!isAskModeAvailable && (
64+
<span className="block text-yellow-600 dark:text-yellow-400 mt-1">
65+
Ask mode is unavailable (no language models configured)
66+
</span>
67+
)}
68+
</CardDescription>
69+
</CardHeader>
70+
<CardContent>
71+
<Select
72+
value={defaultSearchMode}
73+
onValueChange={(value) => setDefaultSearchModeState(value as "precise" | "agentic")}
74+
disabled={isReadOnly}
75+
>
76+
<SelectTrigger>
77+
<SelectValue placeholder="Select default search mode">
78+
{defaultSearchMode === "precise" ? "Code Search" : defaultSearchMode === "agentic" ? "Ask" : undefined}
79+
</SelectValue>
80+
</SelectTrigger>
81+
<SelectContent>
82+
<SelectItem value="precise">Code Search</SelectItem>
83+
<SelectItem value="agentic" disabled={!isAskModeAvailable}>
84+
Ask {!isAskModeAvailable && "(unavailable)"}
85+
</SelectItem>
86+
</SelectContent>
87+
</Select>
88+
</CardContent>
89+
<CardFooter>
90+
<LoadingButton
91+
onClick={handleUpdateDefaultSearchMode}
92+
loading={isUpdating}
93+
disabled={isReadOnly || isUpdating || defaultSearchMode === effectiveInitialMode}
94+
>
95+
Update
96+
</LoadingButton>
97+
</CardFooter>
98+
</Card>
99+
);
100+
};

packages/web/src/app/[domain]/settings/(general)/page.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ChangeOrgNameCard } from "./components/changeOrgNameCard";
22
import { isServiceError } from "@/lib/utils";
3-
import { getCurrentUserRole } from "@/actions";
3+
import { getCurrentUserRole, getDefaultSearchMode } from "@/actions";
44
import { getOrgFromDomain } from "@/data/org";
55
import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
6+
import { DefaultSearchModeCard } from "./components/defaultSearchModeCard";
67
import { ServiceErrorException } from "@/lib/serviceError";
78
import { ErrorCode } from "@/lib/errorCodes";
89
import { headers } from "next/headers";
10+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
911

1012
interface GeneralSettingsPageProps {
1113
params: Promise<{
@@ -36,6 +38,14 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp
3638

3739
const host = (await headers()).get('host') ?? '';
3840

41+
// Get the default search mode setting
42+
const defaultSearchMode = await getDefaultSearchMode(domain);
43+
const initialDefaultMode = isServiceError(defaultSearchMode) ? "precise" : defaultSearchMode;
44+
45+
// Get available language models to determine if "Ask" mode is available
46+
const languageModels = await getConfiguredLanguageModelsInfo();
47+
const isAskModeAvailable = languageModels.length > 0;
48+
3949
return (
4050
<div className="flex flex-col gap-6">
4151
<div>
@@ -52,6 +62,12 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp
5262
currentUserRole={currentUserRole}
5363
rootDomain={host}
5464
/>
65+
66+
<DefaultSearchModeCard
67+
initialDefaultMode={initialDefaultMode}
68+
currentUserRole={currentUserRole}
69+
isAskModeAvailable={isAskModeAvailable}
70+
/>
5571
</div>
5672
)
5773
}

packages/web/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22

33
export const orgMetadataSchema = z.object({
44
anonymousAccessEnabled: z.boolean().optional(),
5+
defaultSearchMode: z.enum(["precise", "agentic"]).optional(),
56
})
67

78
export const demoSearchScopeSchema = z.object({

0 commit comments

Comments
 (0)