diff --git a/packages/web/src/app/[domain]/chat/[id]/page.tsx b/packages/web/src/app/[domain]/chat/[id]/page.tsx index d37076cd..4929589b 100644 --- a/packages/web/src/app/[domain]/chat/[id]/page.tsx +++ b/packages/web/src/app/[domain]/chat/[id]/page.tsx @@ -56,6 +56,7 @@ export default async function Page(props: PageProps) { <>
/ diff --git a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx index b2080df3..7de6b928 100644 --- a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx @@ -9,6 +9,7 @@ import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { SearchModeSelector } from "../../components/searchModeSelector"; +import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; interface LandingPageChatBox { languageModels: LanguageModelInfo[]; @@ -24,9 +25,13 @@ export const LandingPageChatBox = ({ const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); + const isChatBoxDisabled = languageModels.length === 0; + return ( -
-
+
+ + +
{ createNewChatThread(children, selectedSearchScopes); @@ -37,6 +42,7 @@ export const LandingPageChatBox = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} onContextSelectorOpenChanged={setIsContextSelectorOpen} + isDisabled={isChatBoxDisabled} />
@@ -57,6 +63,10 @@ export const LandingPageChatBox = ({
+ + {isChatBoxDisabled && ( + + )}
) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx index 6661ae42..d9fadb89 100644 --- a/packages/web/src/app/[domain]/components/topBar.tsx +++ b/packages/web/src/app/[domain]/components/topBar.tsx @@ -8,18 +8,20 @@ import { Separator } from "@/components/ui/separator"; interface TopBarProps { domain: string; children?: React.ReactNode; + homePath?: string; } export const TopBar = ({ domain, children, + homePath = `/${domain}`, }: TopBarProps) => { return (
diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index 2c9bced2..d7f9368b 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -1,8 +1,8 @@ import { sew, withAuth, withOrgMembership } from "@/actions"; import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions"; import { createAgentStream } from "@/features/chat/agent"; -import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types"; -import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; +import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types"; +import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils"; import { ErrorCode } from "@/lib/errorCodes"; import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; @@ -49,7 +49,11 @@ export async function POST(req: Request) { 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(() => withAuth((userId) => @@ -78,13 +82,13 @@ export async function POST(req: Request) { // corresponding config in `config.json`. const languageModelConfig = (await _getConfiguredLanguageModelsFull()) - .find((model) => model.model === languageModelId); + .find((model) => getLanguageModelKey(model) === getLanguageModelKey(languageModel)); if (!languageModelConfig) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Language model ${languageModelId} is not configured.`, + message: `Language model ${languageModel.model} is not configured.`, }); } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 6ab78a86..ad3df7af 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -109,6 +109,7 @@ --chat-citation-border: hsl(217, 91%, 60%); --warning: #ca8a04; + --error: #fc5c5c; } .dark { @@ -201,6 +202,7 @@ --chat-citation-border: hsl(217, 91%, 60%); --warning: #fde047; + --error: #f87171; } } diff --git a/packages/web/src/features/chat/components/chatBox/atMentionButton.tsx b/packages/web/src/features/chat/components/chatBox/atMentionButton.tsx new file mode 100644 index 00000000..fb17b4ee --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/atMentionButton.tsx @@ -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 ( + + + + + + + + + ); +} \ No newline at end of file diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 934199f2..8876bd69 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -27,6 +27,7 @@ interface ChatBoxProps { className?: string; isRedirecting?: boolean; isGenerating?: boolean; + isDisabled?: boolean; languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; @@ -40,6 +41,7 @@ export const ChatBox = ({ className, isRedirecting, isGenerating, + isDisabled, languageModels, selectedSearchScopes, searchContexts, @@ -68,7 +70,7 @@ export const ChatBox = ({ }).flat(), }); const { selectedLanguageModel } = useSelectedLanguageModel({ - initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, + languageModels, }); const { toast } = useToast(); @@ -167,6 +169,13 @@ export const ChatBox = ({ onContextSelectorOpenChanged(true); } + if (isSubmitDisabledReason === "no-language-model-selected") { + toast({ + description: "⚠️ You must select a language model", + variant: "destructive", + }); + } + return; } @@ -287,6 +296,7 @@ export const ChatBox = ({ renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} + readOnly={isDisabled} />
{isRedirecting ? ( diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx index 8bb00cef..a0aae38c 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx @@ -1,18 +1,12 @@ 'use client'; -import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { LanguageModelInfo, SearchScope } from "@/features/chat/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 { AtMentionButton } from "./atMentionButton"; import { LanguageModelSelector } from "./languageModelSelector"; import { SearchScopeSelector } from "./searchScopeSelector"; -import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard"; -import { AtMentionInfoCard } from "@/features/chat/components/chatBox/atMentionInfoCard"; export interface ChatBoxToolbarProps { languageModels: LanguageModelInfo[]; @@ -33,67 +27,29 @@ export const ChatBoxToolbar = ({ isContextSelectorOpen, onContextSelectorOpenChanged, }: ChatBoxToolbarProps) => { - const editor = useSlate(); - - const onAddContext = useCallback(() => { - editor.insertText("@"); - ReactEditor.focus(editor); - }, [editor]); - const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({ - initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, + languageModels, }); return ( <> - - - - - - - - + - - - - - - - - - {languageModels.length > 0 && ( - <> - - - -
- -
-
-
- - )} + + + ) } diff --git a/packages/web/src/features/chat/components/chatBox/languageModelInfoCard.tsx b/packages/web/src/features/chat/components/chatBox/languageModelInfoCard.tsx new file mode 100644 index 00000000..7b24a42d --- /dev/null +++ b/packages/web/src/features/chat/components/chatBox/languageModelInfoCard.tsx @@ -0,0 +1,16 @@ +import { BotIcon } from "lucide-react"; +import Link from "next/link"; + +export const LanguageModelInfoCard = () => { + return ( +
+
+ +

Language Model

+
+
+ Select the language model to use for the chat. Configuration docs. +
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx b/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx index 791fa8c8..c8fc0196 100644 --- a/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/languageModelSelector.tsx @@ -23,6 +23,9 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { ModelProviderLogo } from "./modelProviderLogo"; +import { getLanguageModelKey } from "../../utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LanguageModelInfoCard } from "./languageModelInfoCard"; interface LanguageModelSelectorProps { languageModels: LanguageModelInfo[]; @@ -59,7 +62,7 @@ export const LanguageModelSelector = ({ // De-duplicate models const languageModels = useMemo(() => { return _languageModels.filter((model, selfIndex, selfArray) => - selfIndex === selfArray.findIndex((t) => t.model === model.model) + selfIndex === selfArray.findIndex((t) => getLanguageModelKey(t) === getLanguageModelKey(model)) ); }, [_languageModels]); @@ -68,81 +71,89 @@ export const LanguageModelSelector = ({ open={isPopoverOpen} onOpenChange={setIsPopoverOpen} > - -
- - - setIsPopoverOpen(false)} - > - - - - No models found. - - {languageModels - .map((model, index) => { - const isSelected = selectedModel?.model === model.model; - return ( - { - selectModel(model) - }} - className="cursor-pointer" - > -
- -
- - {model.displayName ?? model.model} -
- ); - })} -
-
-
-
+
+ {selectedModel ? ( + + ) : ( + + )} + + {selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"} + + +
+ + + + + + + setIsPopoverOpen(false)} + > + + + + +

No models found.

+
+ + {languageModels + .map((model) => { + const isSelected = selectedModel && getLanguageModelKey(selectedModel) === getLanguageModelKey(model); + return ( + { + selectModel(model) + }} + className="cursor-pointer" + > +
+ +
+ + {model.displayName ?? model.model} +
+ ); + })} +
+
+
+
+ ); }; diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index 0c58ae67..e7a49ba9 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -25,6 +25,7 @@ import { usePrevious } from '@uidotdev/usehooks'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { generateAndUpdateChatNameFromMessage } from '../../actions'; import { isServiceError } from '@/lib/utils'; +import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; type ChatHistoryState = { scrollOffset?: number; @@ -73,7 +74,7 @@ export const ChatThread = ({ ); const { selectedLanguageModel } = useSelectedLanguageModel({ - initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, + languageModels, }); const { @@ -118,7 +119,7 @@ export const ChatThread = ({ _sendMessage(message, { body: { selectedSearchScopes, - languageModelId: selectedLanguageModel.model, + languageModel: selectedLanguageModel, } satisfies AdditionalChatRequestParams, }); @@ -355,31 +356,38 @@ export const ChatThread = ({ } {!isChatReadonly && ( -
- - -
- + {languageModels.length === 0 && ( + + )} + +
+ + -
- +
+ +
+ +
)} diff --git a/packages/web/src/features/chat/components/notConfiguredErrorBanner.tsx b/packages/web/src/features/chat/components/notConfiguredErrorBanner.tsx new file mode 100644 index 00000000..3b142bf1 --- /dev/null +++ b/packages/web/src/features/chat/components/notConfiguredErrorBanner.tsx @@ -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 ( +
+ + Ask unavailable: no language model configured. See the configuration docs for more information. +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 524f14f8..8d543b1a 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -170,6 +170,13 @@ export type LanguageModelProvider = LanguageModel['provider']; // This is a subset of information about a configured // 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 = { provider: LanguageModelProvider, model: LanguageModel['model'], @@ -178,7 +185,7 @@ export type LanguageModelInfo = { // Additional request body data that we send along to the chat API. export const additionalChatRequestParamsSchema = z.object({ - languageModelId: z.string(), + languageModel: languageModelInfoSchema, selectedSearchScopes: z.array(searchScopeSchema), }); export type AdditionalChatRequestParams = z.infer; \ No newline at end of file diff --git a/packages/web/src/features/chat/useSelectedLanguageModel.ts b/packages/web/src/features/chat/useSelectedLanguageModel.ts index 7cdc79e4..a22b5940 100644 --- a/packages/web/src/features/chat/useSelectedLanguageModel.ts +++ b/packages/web/src/features/chat/useSelectedLanguageModel.ts @@ -2,22 +2,40 @@ import { useLocalStorage } from "usehooks-ts"; import { LanguageModelInfo } from "./types"; +import { useEffect } from "react"; +import { getLanguageModelKey } from "./utils"; type Props = { - initialLanguageModel?: LanguageModelInfo; + languageModels: LanguageModelInfo[]; } export const useSelectedLanguageModel = ({ - initialLanguageModel, -}: Props = {}) => { + languageModels, +}: Props) => { + const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined; const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage( "selectedLanguageModel", - initialLanguageModel, + fallbackLanguageModel, { 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 { selectedLanguageModel, setSelectedLanguageModel, diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index c57f3fd5..f339b872 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -6,6 +6,7 @@ import { CustomText, FileReference, FileSource, + LanguageModelInfo, MentionData, MentionElement, ParagraphElement, @@ -365,4 +366,11 @@ export const buildSearchQuery = (options: { } return query; -} \ No newline at end of file +} + +/** + * Generates a unique key given a LanguageModelInfo object. + */ +export const getLanguageModelKey = (model: LanguageModelInfo) => { + return `${model.provider}-${model.model}-${model.displayName}`; +} diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 1f7acf4d..c2859825 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -67,6 +67,7 @@ const config = { ring: 'var(--sidebar-ring)' }, warning: 'var(--warning)', + error: 'var(--error)', editor: { background: 'var(--editor-background)', foreground: 'var(--editor-foreground)',