Merge remote-tracking branch 'upstream/dev' into pr-611

This commit is contained in:
Anish Sarkar 2025-12-23 15:45:28 +05:30
commit 6f330e7b8d
92 changed files with 5331 additions and 6029 deletions

View file

@ -0,0 +1,66 @@
"use client";
import { useCallback, useState } from "react";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { ModelConfigSidebar } from "./model-config-sidebar";
import { ModelSelector } from "./model-selector";
interface ChatHeaderProps {
searchSpaceId: number;
}
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null
>(null);
const [isGlobal, setIsGlobal] = useState(false);
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
const handleEditConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
setSelectedConfig(config);
setIsGlobal(global);
setSidebarMode(global ? "view" : "edit");
setSidebarOpen(true);
},
[]
);
const handleAddNew = useCallback(() => {
setSelectedConfig(null);
setIsGlobal(false);
setSidebarMode("create");
setSidebarOpen(true);
}, []);
const handleSidebarClose = useCallback((open: boolean) => {
setSidebarOpen(open);
if (!open) {
// Reset state when closing
setSelectedConfig(null);
}
}, []);
return (
<>
{/* Header Bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border/30 bg-background/80 backdrop-blur-sm">
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
</div>
{/* Config Sidebar */}
<ModelConfigSidebar
open={sidebarOpen}
onOpenChange={handleSidebarClose}
config={selectedConfig}
isGlobal={isGlobal}
searchSpaceId={searchSpaceId}
mode={sidebarMode}
/>
</>
);
}

View file

@ -0,0 +1,369 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ModelConfigSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
export function ModelConfigSidebar({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ModelConfigSidebarProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
// Mutations - use mutateAsync from the atom value
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Get title based on mode
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
// Handle form submit
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
// Create new config
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
// Assign the new config to the agent role
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
toast.success("Configuration created and assigned!");
onOpenChange(false);
} else if (!isGlobal && config) {
// Update existing user config
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
toast.success("Configuration updated!");
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
toast.error("Failed to save configuration");
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
// Handle "Use this model" for global configs
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
toast.error("Failed to set model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Sidebar Panel */}
<motion.div
initial={{ x: "100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100%", opacity: 0 }}
transition={{
type: "spring",
damping: 30,
stiffness: 300,
}}
className={cn(
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
<Bot className="size-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isGlobal ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Globe className="size-3" />
Global
</Badge>
) : mode !== "create" ? (
<Badge variant="outline" className="gap-1 text-xs">
<User className="size-3" />
Custom
</Badge>
) : null}
{config && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="rounded-xl hover:bg-destructive/10 hover:text-destructive"
>
<X className="size-5" />
</Button>
</div>
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
<div className="flex-1 overflow-y-auto">
<div className="p-6">
{/* Global config notice */}
{isGlobal && mode !== "create" && (
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{/* Form */}
{mode === "create" ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting}
mode="create"
submitLabel="Create & Use"
/>
) : isGlobal && config ? (
// Read-only view for global configs
<div className="space-y-6">
{/* Config Details */}
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name
</label>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</label>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</label>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</label>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</label>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</label>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use This Model
</>
)}
</Button>
</div>
</div>
) : config ? (
// Edit form for user configs
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: config.api_key,
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting}
mode="edit"
submitLabel="Save Changes"
/>
) : null}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,384 @@
"use client";
import { useAtomValue } from "jotai";
import {
Bot,
Check,
ChevronDown,
Cloud,
Edit3,
Globe,
Loader2,
Plus,
Settings2,
Sparkles,
User,
Zap,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
// Provider icons mapping
const getProviderIcon = (provider: string) => {
const iconClass = "size-4";
switch (provider?.toUpperCase()) {
case "OPENAI":
return <Sparkles className={cn(iconClass, "text-emerald-500")} />;
case "ANTHROPIC":
return <Bot className={cn(iconClass, "text-amber-600")} />;
case "GOOGLE":
return <Cloud className={cn(iconClass, "text-blue-500")} />;
case "GROQ":
return <Zap className={cn(iconClass, "text-orange-500")} />;
case "OLLAMA":
return <Settings2 className={cn(iconClass, "text-gray-500")} />;
case "XAI":
return <Bot className={cn(iconClass, "text-violet-500")} />;
default:
return <Bot className={cn(iconClass, "text-muted-foreground")} />;
}
};
interface ModelSelectorProps {
onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
onAddNew: () => void;
className?: string;
}
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs, isLoading: globalConfigsLoading } =
useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading;
// Get current agent LLM config
const currentConfig = useMemo(() => {
if (!preferences) return null;
const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return null;
// Check if it's a global config (negative ID)
if (agentLlmId < 0) {
return globalConfigs?.find((c) => c.id === agentLlmId) ?? null;
}
// Otherwise, check user configs
return userConfigs?.find((c) => c.id === agentLlmId) ?? null;
}, [preferences, globalConfigs, userConfigs]);
// Filter configs based on search
const filteredGlobalConfigs = useMemo(() => {
if (!globalConfigs) return [];
if (!searchQuery) return globalConfigs;
const query = searchQuery.toLowerCase();
return globalConfigs.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.model_name.toLowerCase().includes(query) ||
c.provider.toLowerCase().includes(query)
);
}, [globalConfigs, searchQuery]);
const filteredUserConfigs = useMemo(() => {
if (!userConfigs) return [];
if (!searchQuery) return userConfigs;
const query = searchQuery.toLowerCase();
return userConfigs.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.model_name.toLowerCase().includes(query) ||
c.provider.toLowerCase().includes(query)
);
}, [userConfigs, searchQuery]);
const handleSelectConfig = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
// If already selected, just close
if (currentConfig?.id === config.id) {
setOpen(false);
return;
}
if (!searchSpaceId) {
toast.error("No search space selected");
return;
}
setIsSwitching(true);
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
data: {
agent_llm_id: config.id,
},
});
toast.success(`Switched to ${config.name}`);
setOpen(false);
} catch (error) {
console.error("Failed to switch model:", error);
toast.error("Failed to switch model");
} finally {
setIsSwitching(false);
}
},
[currentConfig, searchSpaceId, updatePreferences]
);
const handleEditConfig = useCallback(
(e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => {
e.stopPropagation();
onEdit(config, isGlobal);
setOpen(false);
},
[onEdit]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={open}
className={cn(
"h-9 gap-2 px-3 rounded-xl border border-border/50 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border transition-all duration-200",
"text-sm font-medium text-foreground",
className
)}
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</>
) : currentConfig ? (
<>
{getProviderIcon(currentConfig.provider)}
<span className="max-w-[150px] truncate">{currentConfig.name}</span>
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
{currentConfig.model_name.split("/").pop()?.slice(0, 15) ||
currentConfig.model_name.slice(0, 15)}
</Badge>
</>
) : (
<>
<Bot className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">Select Model</span>
</>
)}
<ChevronDown className="size-3.5 text-muted-foreground ml-1 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[360px] p-0 rounded-xl shadow-lg border-border/50"
align="start"
sideOffset={8}
>
<Command shouldFilter={false} className="rounded-xl relative">
{/* Switching overlay */}
{isSwitching && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Switching model...</span>
</div>
</div>
)}
<div className="flex items-center gap-2 border-b px-3 py-2 bg-muted/30">
<Bot className="size-4 text-muted-foreground" />
<CommandInput
placeholder="Search models..."
value={searchQuery}
onValueChange={setSearchQuery}
className="h-8 border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
disabled={isSwitching}
/>
</div>
<CommandList className="max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div>
</CommandEmpty>
{/* Global Configs Section */}
{filteredGlobalConfigs.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<Globe className="size-3.5" />
Global Models
</div>
{filteredGlobalConfigs.map((config) => {
const isSelected = currentConfig?.id === config.id;
return (
<CommandItem
key={`global-${config.id}`}
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
{config.model_name}
</span>
{config.citations_enabled && (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
>
Citations
</Badge>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
onClick={(e) => handleEditConfig(e, config, true)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && (
<CommandSeparator className="my-1" />
)}
{/* User Configs Section */}
{filteredUserConfigs.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<User className="size-3.5" />
Your Configurations
</div>
{filteredUserConfigs.map((config) => {
const isSelected = currentConfig?.id === config.id;
return (
<CommandItem
key={`user-${config.id}`}
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
{config.model_name}
</span>
{config.citations_enabled && (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
>
Citations
</Badge>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
onClick={(e) => handleEditConfig(e, config, false)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{/* Add New Config Button */}
<div className="p-2 border-t border-border/50 bg-muted/20">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
onClick={() => {
setOpen(false);
onAddNew();
}}
>
<Plus className="size-4 text-primary" />
<span className="text-sm font-medium">Add New Configuration</span>
</Button>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}