further improvements

This commit is contained in:
bkellam 2025-10-16 16:04:58 -07:00
parent c1467bcd82
commit 154c95f4ee
17 changed files with 285 additions and 181 deletions

View file

@ -56,6 +56,7 @@ export default async function Page(props: PageProps) {
<> <>
<TopBar <TopBar
domain={params.domain} domain={params.domain}
homePath={`/${params.domain}/chat`}
> >
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<span className="text-muted mx-2 select-none">/</span> <span className="text-muted mx-2 select-none">/</span>

View file

@ -9,6 +9,7 @@ import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { SearchModeSelector } from "../../components/searchModeSelector"; import { SearchModeSelector } from "../../components/searchModeSelector";
import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner";
interface LandingPageChatBox { interface LandingPageChatBox {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
@ -24,9 +25,13 @@ export const LandingPageChatBox = ({
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false }); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const isChatBoxDisabled = languageModels.length === 0;
return ( return (
<div className="flex flex-col items-center w-full"> <div className="w-full max-w-[800px] mt-4">
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
<div className="border rounded-md w-full shadow-sm">
<ChatBox <ChatBox
onSubmit={(children) => { onSubmit={(children) => {
createNewChatThread(children, selectedSearchScopes); createNewChatThread(children, selectedSearchScopes);
@ -37,6 +42,7 @@ export const LandingPageChatBox = ({
selectedSearchScopes={selectedSearchScopes} selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
isDisabled={isChatBoxDisabled}
/> />
<Separator /> <Separator />
<div className="relative"> <div className="relative">
@ -57,6 +63,10 @@ export const LandingPageChatBox = ({
</div> </div>
</div> </div>
</div> </div>
{isChatBoxDisabled && (
<NotConfiguredErrorBanner className="mt-4" />
)}
</div > </div >
) )
} }

View file

@ -8,18 +8,20 @@ import { Separator } from "@/components/ui/separator";
interface TopBarProps { interface TopBarProps {
domain: string; domain: string;
children?: React.ReactNode; children?: React.ReactNode;
homePath?: string;
} }
export const TopBar = ({ export const TopBar = ({
domain, domain,
children, children,
homePath = `/${domain}`,
}: TopBarProps) => { }: TopBarProps) => {
return ( return (
<div className='sticky top-0 left-0 right-0 z-10'> <div className='sticky top-0 left-0 right-0 z-10'>
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background"> <div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
<div className="grow flex flex-row gap-4 items-center"> <div className="grow flex flex-row gap-4 items-center">
<Link <Link
href={`/${domain}`} href={homePath}
className="shrink-0 cursor-pointer" className="shrink-0 cursor-pointer"
> >
<Image <Image

View file

@ -9,14 +9,8 @@ export default async function Layout(
props: LayoutProps props: LayoutProps
) { ) {
const params = await props.params; const params = await props.params;
const { domain } = params;
const { const { children } = props;
domain
} = params;
const {
children
} = props;
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">

View file

@ -1,8 +1,8 @@
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew, withAuth, withOrgMembership } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions"; import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent"; import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types"; import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
@ -49,7 +49,11 @@ export async function POST(req: Request) {
return serviceErrorResponse(schemaValidationError(parsed.error)); return serviceErrorResponse(schemaValidationError(parsed.error));
} }
const { messages, id, selectedSearchScopes, languageModelId } = parsed.data; const { messages, id, selectedSearchScopes, languageModel: _languageModel } = parsed.data;
// @note: a bit of type massaging is required here since the
// zod schema does not enum on `model` or `provider`.
// @see: chat/types.ts
const languageModel = _languageModel as LanguageModelInfo;
const response = await sew(() => const response = await sew(() =>
withAuth((userId) => withAuth((userId) =>
@ -78,13 +82,13 @@ export async function POST(req: Request) {
// corresponding config in `config.json`. // corresponding config in `config.json`.
const languageModelConfig = const languageModelConfig =
(await _getConfiguredLanguageModelsFull()) (await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId); .find((model) => getLanguageModelKey(model) === getLanguageModelKey(languageModel));
if (!languageModelConfig) { if (!languageModelConfig) {
return serviceErrorResponse({ return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST, statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY, errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`, message: `Language model ${languageModel.model} is not configured.`,
}); });
} }

View file

@ -109,6 +109,7 @@
--chat-citation-border: hsl(217, 91%, 60%); --chat-citation-border: hsl(217, 91%, 60%);
--warning: #ca8a04; --warning: #ca8a04;
--error: #fc5c5c;
} }
.dark { .dark {
@ -201,6 +202,7 @@
--chat-citation-border: hsl(217, 91%, 60%); --chat-citation-border: hsl(217, 91%, 60%);
--warning: #fde047; --warning: #fde047;
--error: #f87171;
} }
} }

View file

@ -0,0 +1,38 @@
'use client';
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AtSignIcon } from "lucide-react";
import { useCallback } from "react";
import { ReactEditor, useSlate } from "slate-react";
import { AtMentionInfoCard } from "./atMentionInfoCard";
// @note: we have this as a seperate component to avoid having to re-render the
// entire toolbar whenever the user types (since we are using the useSlate hook
// here).
export const AtMentionButton = () => {
const editor = useSlate();
const onAddContext = useCallback(() => {
editor.insertText("@");
ReactEditor.focus(editor);
}, [editor]);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-6 h-6 text-muted-foreground hover:text-primary"
onClick={onAddContext}
>
<AtSignIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
<AtMentionInfoCard />
</TooltipContent>
</Tooltip>
);
}

View file

@ -27,6 +27,7 @@ interface ChatBoxProps {
className?: string; className?: string;
isRedirecting?: boolean; isRedirecting?: boolean;
isGenerating?: boolean; isGenerating?: boolean;
isDisabled?: boolean;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
selectedSearchScopes: SearchScope[]; selectedSearchScopes: SearchScope[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
@ -40,6 +41,7 @@ export const ChatBox = ({
className, className,
isRedirecting, isRedirecting,
isGenerating, isGenerating,
isDisabled,
languageModels, languageModels,
selectedSearchScopes, selectedSearchScopes,
searchContexts, searchContexts,
@ -68,7 +70,7 @@ export const ChatBox = ({
}).flat(), }).flat(),
}); });
const { selectedLanguageModel } = useSelectedLanguageModel({ const { selectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, languageModels,
}); });
const { toast } = useToast(); const { toast } = useToast();
@ -167,6 +169,13 @@ export const ChatBox = ({
onContextSelectorOpenChanged(true); onContextSelectorOpenChanged(true);
} }
if (isSubmitDisabledReason === "no-language-model-selected") {
toast({
description: "⚠️ You must select a language model",
variant: "destructive",
});
}
return; return;
} }
@ -287,6 +296,7 @@ export const ChatBox = ({
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
readOnly={isDisabled}
/> />
<div className="ml-auto z-10"> <div className="ml-auto z-10">
{isRedirecting ? ( {isRedirecting ? (

View file

@ -1,18 +1,12 @@
'use client'; 'use client';
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { AtSignIcon } from "lucide-react";
import { useCallback } from "react";
import { ReactEditor, useSlate } from "slate-react";
import { useSelectedLanguageModel } from "../../useSelectedLanguageModel"; import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
import { AtMentionButton } from "./atMentionButton";
import { LanguageModelSelector } from "./languageModelSelector"; import { LanguageModelSelector } from "./languageModelSelector";
import { SearchScopeSelector } from "./searchScopeSelector"; import { SearchScopeSelector } from "./searchScopeSelector";
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
import { AtMentionInfoCard } from "@/features/chat/components/chatBox/atMentionInfoCard";
export interface ChatBoxToolbarProps { export interface ChatBoxToolbarProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
@ -33,67 +27,29 @@ export const ChatBoxToolbar = ({
isContextSelectorOpen, isContextSelectorOpen,
onContextSelectorOpenChanged, onContextSelectorOpenChanged,
}: ChatBoxToolbarProps) => { }: ChatBoxToolbarProps) => {
const editor = useSlate();
const onAddContext = useCallback(() => {
editor.insertText("@");
ReactEditor.focus(editor);
}, [editor]);
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({ const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, languageModels,
}); });
return ( return (
<> <>
<Tooltip> <AtMentionButton />
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-6 h-6 text-muted-foreground hover:text-primary"
onClick={onAddContext}
>
<AtSignIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
<AtMentionInfoCard />
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-3 mx-1" /> <Separator orientation="vertical" className="h-3 mx-1" />
<Tooltip> <SearchScopeSelector
<TooltipTrigger asChild> className="bg-inherit w-fit h-6 min-h-6"
<SearchScopeSelector repos={repos}
className="bg-inherit w-fit h-6 min-h-6" searchContexts={searchContexts}
repos={repos} selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts} onSelectedSearchScopesChange={onSelectedSearchScopesChange}
selectedSearchScopes={selectedSearchScopes} isOpen={isContextSelectorOpen}
onSelectedSearchScopesChange={onSelectedSearchScopesChange} onOpenChanged={onContextSelectorOpenChanged}
isOpen={isContextSelectorOpen} />
onOpenChanged={onContextSelectorOpenChanged} <Separator orientation="vertical" className="h-3 ml-1 mr-2" />
/> <LanguageModelSelector
</TooltipTrigger> languageModels={languageModels}
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none"> onSelectedModelChange={setSelectedLanguageModel}
<SearchScopeInfoCard /> selectedModel={selectedLanguageModel}
</TooltipContent> />
</Tooltip>
{languageModels.length > 0 && (
<>
<Separator orientation="vertical" className="h-3 ml-1 mr-2" />
<Tooltip>
<TooltipTrigger asChild>
<div>
<LanguageModelSelector
languageModels={languageModels}
onSelectedModelChange={setSelectedLanguageModel}
selectedModel={selectedLanguageModel}
/>
</div>
</TooltipTrigger>
</Tooltip>
</>
)}
</> </>
) )
} }

View file

@ -0,0 +1,16 @@
import { BotIcon } from "lucide-react";
import Link from "next/link";
export const LanguageModelInfoCard = () => {
return (
<div className="bg-popover border border-border rounded-lg shadow-lg p-4 w-80 max-w-[90vw]">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/50">
<BotIcon className="h-4 w-4 text-primary" />
<h4 className="text-sm font-semibold text-popover-foreground">Language Model</h4>
</div>
<div className="text-sm text-popover-foreground leading-relaxed">
Select the language model to use for the chat. <Link href="https://docs.sourcebot.dev/docs/configuration/language-model-providers" target="_blank" className="text-link">Configuration docs.</Link>
</div>
</div>
);
};

View file

@ -23,6 +23,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ModelProviderLogo } from "./modelProviderLogo"; import { ModelProviderLogo } from "./modelProviderLogo";
import { getLanguageModelKey } from "../../utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LanguageModelInfoCard } from "./languageModelInfoCard";
interface LanguageModelSelectorProps { interface LanguageModelSelectorProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
@ -59,7 +62,7 @@ export const LanguageModelSelector = ({
// De-duplicate models // De-duplicate models
const languageModels = useMemo(() => { const languageModels = useMemo(() => {
return _languageModels.filter((model, selfIndex, selfArray) => return _languageModels.filter((model, selfIndex, selfArray) =>
selfIndex === selfArray.findIndex((t) => t.model === model.model) selfIndex === selfArray.findIndex((t) => getLanguageModelKey(t) === getLanguageModelKey(model))
); );
}, [_languageModels]); }, [_languageModels]);
@ -68,81 +71,89 @@ export const LanguageModelSelector = ({
open={isPopoverOpen} open={isPopoverOpen}
onOpenChange={setIsPopoverOpen} onOpenChange={setIsPopoverOpen}
> >
<PopoverTrigger asChild> <Tooltip>
<Button <PopoverTrigger asChild>
onClick={handleTogglePopover} <TooltipTrigger asChild>
className={cn( <Button
"flex p-1 rounded-md items-center justify-between bg-inherit h-6", onClick={handleTogglePopover}
className
)}
>
<div className="flex items-center justify-between mx-auto max-w-64 overflow-hidden">
{selectedModel ? (
<ModelProviderLogo
provider={selectedModel.provider}
className="mr-1"
/>
) : (
<Bot className="h-4 w-4 text-muted-foreground mr-1" />
)}
<span
className={cn( className={cn(
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap", "flex p-1 rounded-md items-center justify-between bg-inherit h-6",
selectedModel ? "font-medium" : "font-normal" className
)} )}
> >
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"} <div className="flex items-center justify-between mx-auto max-w-64 overflow-hidden">
</span> {selectedModel ? (
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" /> <ModelProviderLogo
</div> provider={selectedModel.provider}
</Button> className="mr-1"
</PopoverTrigger> />
<PopoverContent ) : (
className="w-auto p-0" <Bot className="h-4 w-4 text-muted-foreground mr-1" />
align="start" )}
onEscapeKeyDown={() => setIsPopoverOpen(false)} <span
> className={cn(
<Command> "text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap font-medium",
<CommandInput )}
placeholder="Search models..." >
onKeyDown={handleInputKeyDown} {selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
/> </span>
<CommandList> <ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
<CommandEmpty>No models found.</CommandEmpty> </div>
<CommandGroup> </Button>
{languageModels </TooltipTrigger>
.map((model, index) => { </PopoverTrigger>
const isSelected = selectedModel?.model === model.model; <TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
return ( <LanguageModelInfoCard />
<CommandItem </TooltipContent>
key={`${model.model}-${index}`} <PopoverContent
onSelect={() => { className="w-auto p-0"
selectModel(model) align="start"
}} onEscapeKeyDown={() => setIsPopoverOpen(false)}
className="cursor-pointer" >
> <Command>
<div <CommandInput
className={cn( placeholder="Search models..."
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", onKeyDown={handleInputKeyDown}
isSelected />
? "bg-primary text-primary-foreground" <CommandList>
: "opacity-50 [&_svg]:invisible" <CommandEmpty>
)} <p>No models found.</p>
> </CommandEmpty>
<CheckIcon className="h-4 w-4" /> <CommandGroup>
</div> {languageModels
<ModelProviderLogo .map((model) => {
provider={model.provider} const isSelected = selectedModel && getLanguageModelKey(selectedModel) === getLanguageModelKey(model);
className="mr-2" return (
/> <CommandItem
<span>{model.displayName ?? model.model}</span> key={getLanguageModelKey(model)}
</CommandItem> onSelect={() => {
); selectModel(model)
})} }}
</CommandGroup> className="cursor-pointer"
</CommandList> >
</Command> <div
</PopoverContent> className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<ModelProviderLogo
provider={model.provider}
className="mr-2"
/>
<span>{model.displayName ?? model.model}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Tooltip>
</Popover> </Popover>
); );
}; };

View file

@ -25,6 +25,7 @@ import { usePrevious } from '@uidotdev/usehooks';
import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
import { generateAndUpdateChatNameFromMessage } from '../../actions'; import { generateAndUpdateChatNameFromMessage } from '../../actions';
import { isServiceError } from '@/lib/utils'; import { isServiceError } from '@/lib/utils';
import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner';
type ChatHistoryState = { type ChatHistoryState = {
scrollOffset?: number; scrollOffset?: number;
@ -73,7 +74,7 @@ export const ChatThread = ({
); );
const { selectedLanguageModel } = useSelectedLanguageModel({ const { selectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, languageModels,
}); });
const { const {
@ -118,7 +119,7 @@ export const ChatThread = ({
_sendMessage(message, { _sendMessage(message, {
body: { body: {
selectedSearchScopes, selectedSearchScopes,
languageModelId: selectedLanguageModel.model, languageModel: selectedLanguageModel,
} satisfies AdditionalChatRequestParams, } satisfies AdditionalChatRequestParams,
}); });
@ -355,31 +356,38 @@ export const ChatThread = ({
} }
</ScrollArea> </ScrollArea>
{!isChatReadonly && ( {!isChatReadonly && (
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm"> <div className="w-full max-w-3xl mx-auto mb-8">
<CustomSlateEditor> {languageModels.length === 0 && (
<ChatBox <NotConfiguredErrorBanner className="mb-2" />
onSubmit={onSubmit} )}
className="min-h-[80px]"
preferredSuggestionsBoxPlacement="top-start" <div className="border rounded-md w-full shadow-sm">
isGenerating={status === "streaming" || status === "submitted"} <CustomSlateEditor>
onStop={stop} <ChatBox
languageModels={languageModels} onSubmit={onSubmit}
selectedSearchScopes={selectedSearchScopes} className="min-h-[80px]"
searchContexts={searchContexts} preferredSuggestionsBoxPlacement="top-start"
onContextSelectorOpenChanged={setIsContextSelectorOpen} isGenerating={status === "streaming" || status === "submitted"}
/> onStop={stop}
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
languageModels={languageModels} languageModels={languageModels}
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes} selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange} searchContexts={searchContexts}
isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
isDisabled={languageModels.length === 0}
/> />
</div> <div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
</CustomSlateEditor> <ChatBoxToolbar
languageModels={languageModels}
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
</div>
</CustomSlateEditor>
</div>
</div> </div>
)} )}
</> </>

View file

@ -0,0 +1,18 @@
import { TriangleAlertIcon } from "lucide-react"
import Link from "next/link"
import { cn } from "@/lib/utils";
const DOCS_URL = "https://docs.sourcebot.dev/docs/configuration/language-model-providers";
interface NotConfiguredErrorBannerProps {
className?: string;
}
export const NotConfiguredErrorBanner = ({ className }: NotConfiguredErrorBannerProps) => {
return (
<div className={cn("flex flex-row items-center bg-error rounded-md p-2", className)}>
<TriangleAlertIcon className="h-4 w-4 text-accent mr-1.5" />
<span className="text-sm font-medium text-accent"><span className="font-bold">Ask unavailable:</span> no language model configured. See the <Link href={DOCS_URL} target="_blank" className="underline">configuration docs</Link> for more information.</span>
</div>
)
}

View file

@ -170,6 +170,13 @@ export type LanguageModelProvider = LanguageModel['provider'];
// This is a subset of information about a configured // This is a subset of information about a configured
// language model that we can safely send to the client. // language model that we can safely send to the client.
// @note: ensure this is in sync with the LanguageModelInfo type.
export const languageModelInfoSchema = z.object({
provider: z.string(),
model: z.string(),
displayName: z.string().optional(),
});
export type LanguageModelInfo = { export type LanguageModelInfo = {
provider: LanguageModelProvider, provider: LanguageModelProvider,
model: LanguageModel['model'], model: LanguageModel['model'],
@ -178,7 +185,7 @@ export type LanguageModelInfo = {
// Additional request body data that we send along to the chat API. // Additional request body data that we send along to the chat API.
export const additionalChatRequestParamsSchema = z.object({ export const additionalChatRequestParamsSchema = z.object({
languageModelId: z.string(), languageModel: languageModelInfoSchema,
selectedSearchScopes: z.array(searchScopeSchema), selectedSearchScopes: z.array(searchScopeSchema),
}); });
export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>; export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>;

View file

@ -2,22 +2,40 @@
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { LanguageModelInfo } from "./types"; import { LanguageModelInfo } from "./types";
import { useEffect } from "react";
import { getLanguageModelKey } from "./utils";
type Props = { type Props = {
initialLanguageModel?: LanguageModelInfo; languageModels: LanguageModelInfo[];
} }
export const useSelectedLanguageModel = ({ export const useSelectedLanguageModel = ({
initialLanguageModel, languageModels,
}: Props = {}) => { }: Props) => {
const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined;
const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>( const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
"selectedLanguageModel", "selectedLanguageModel",
initialLanguageModel, fallbackLanguageModel,
{ {
initializeWithValue: false, initializeWithValue: false,
} }
); );
// Handle the case where the selected language model is no longer
// available. Reset to the fallback language model in this case.
useEffect(() => {
if (!selectedLanguageModel || !languageModels.find(
(model) => getLanguageModelKey(model) === getLanguageModelKey(selectedLanguageModel)
)) {
setSelectedLanguageModel(fallbackLanguageModel);
}
}, [
fallbackLanguageModel,
languageModels,
selectedLanguageModel,
setSelectedLanguageModel,
]);
return { return {
selectedLanguageModel, selectedLanguageModel,
setSelectedLanguageModel, setSelectedLanguageModel,

View file

@ -6,6 +6,7 @@ import {
CustomText, CustomText,
FileReference, FileReference,
FileSource, FileSource,
LanguageModelInfo,
MentionData, MentionData,
MentionElement, MentionElement,
ParagraphElement, ParagraphElement,
@ -366,3 +367,10 @@ export const buildSearchQuery = (options: {
return query; return query;
} }
/**
* Generates a unique key given a LanguageModelInfo object.
*/
export const getLanguageModelKey = (model: LanguageModelInfo) => {
return `${model.provider}-${model.model}-${model.displayName}`;
}

View file

@ -67,6 +67,7 @@ const config = {
ring: 'var(--sidebar-ring)' ring: 'var(--sidebar-ring)'
}, },
warning: 'var(--warning)', warning: 'var(--warning)',
error: 'var(--error)',
editor: { editor: {
background: 'var(--editor-background)', background: 'var(--editor-background)',
foreground: 'var(--editor-foreground)', foreground: 'var(--editor-foreground)',