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
domain={params.domain}
homePath={`/${params.domain}/chat`}
>
<div className="flex flex-row gap-2 items-center">
<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 { 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<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const isChatBoxDisabled = languageModels.length === 0;
return (
<div className="flex flex-col items-center w-full">
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
<div className="w-full max-w-[800px] mt-4">
<div className="border rounded-md w-full shadow-sm">
<ChatBox
onSubmit={(children) => {
createNewChatThread(children, selectedSearchScopes);
@ -37,6 +42,7 @@ export const LandingPageChatBox = ({
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
isDisabled={isChatBoxDisabled}
/>
<Separator />
<div className="relative">
@ -57,6 +63,10 @@ export const LandingPageChatBox = ({
</div>
</div>
</div>
{isChatBoxDisabled && (
<NotConfiguredErrorBanner className="mt-4" />
)}
</div >
)
}

View file

@ -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 (
<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="grow flex flex-row gap-4 items-center">
<Link
href={`/${domain}`}
href={homePath}
className="shrink-0 cursor-pointer"
>
<Image

View file

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

View file

@ -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.`,
});
}

View file

@ -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;
}
}

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;
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}
/>
<div className="ml-auto z-10">
{isRedirecting ? (

View file

@ -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 (
<>
<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>
<AtMentionButton />
<Separator orientation="vertical" className="h-3 mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<SearchScopeSelector
className="bg-inherit w-fit h-6 min-h-6"
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isOpen={isContextSelectorOpen}
onOpenChanged={onContextSelectorOpenChanged}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
<SearchScopeInfoCard />
</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>
</>
)}
<SearchScopeSelector
className="bg-inherit w-fit h-6 min-h-6"
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isOpen={isContextSelectorOpen}
onOpenChanged={onContextSelectorOpenChanged}
/>
<Separator orientation="vertical" className="h-3 ml-1 mr-2" />
<LanguageModelSelector
languageModels={languageModels}
onSelectedModelChange={setSelectedLanguageModel}
selectedModel={selectedLanguageModel}
/>
</>
)
}

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";
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}
>
<PopoverTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
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
<Tooltip>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap",
selectedModel ? "font-medium" : "font-normal"
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
className
)}
>
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search models..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{languageModels
.map((model, index) => {
const isSelected = selectedModel?.model === model.model;
return (
<CommandItem
key={`${model.model}-${index}`}
onSelect={() => {
selectModel(model)
}}
className="cursor-pointer"
>
<div
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>
<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(
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap font-medium",
)}
>
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
</div>
</Button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
<LanguageModelInfoCard />
</TooltipContent>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search models..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>
<p>No models found.</p>
</CommandEmpty>
<CommandGroup>
{languageModels
.map((model) => {
const isSelected = selectedModel && getLanguageModelKey(selectedModel) === getLanguageModelKey(model);
return (
<CommandItem
key={getLanguageModelKey(model)}
onSelect={() => {
selectModel(model)
}}
className="cursor-pointer"
>
<div
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>
);
};

View file

@ -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 = ({
}
</ScrollArea>
{!isChatReadonly && (
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
<CustomSlateEditor>
<ChatBox
onSubmit={onSubmit}
className="min-h-[80px]"
preferredSuggestionsBoxPlacement="top-start"
isGenerating={status === "streaming" || status === "submitted"}
onStop={stop}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
<div className="w-full max-w-3xl mx-auto mb-8">
{languageModels.length === 0 && (
<NotConfiguredErrorBanner className="mb-2" />
)}
<div className="border rounded-md w-full shadow-sm">
<CustomSlateEditor>
<ChatBox
onSubmit={onSubmit}
className="min-h-[80px]"
preferredSuggestionsBoxPlacement="top-start"
isGenerating={status === "streaming" || status === "submitted"}
onStop={stop}
languageModels={languageModels}
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isContextSelectorOpen={isContextSelectorOpen}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
isDisabled={languageModels.length === 0}
/>
</div>
</CustomSlateEditor>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar
languageModels={languageModels}
repos={repos}
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
</div>
</CustomSlateEditor>
</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
// 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<typeof additionalChatRequestParamsSchema>;

View file

@ -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<LanguageModelInfo | undefined>(
"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,

View file

@ -6,6 +6,7 @@ import {
CustomText,
FileReference,
FileSource,
LanguageModelInfo,
MentionData,
MentionElement,
ParagraphElement,
@ -365,4 +366,11 @@ export const buildSearchQuery = (options: {
}
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)'
},
warning: 'var(--warning)',
error: 'var(--error)',
editor: {
background: 'var(--editor-background)',
foreground: 'var(--editor-foreground)',