Fix and Improve Chat sidebar and component setup on Web App (#1157)

- Set chatSidebar prompt, Setting name fields to empty str if value null
- Track if agent modified in chatSidebar to simplify code, fix looping
- Suppress spurious dark mode hydration warnings on the web app
- Set key for chatMessage parent to get UX efficiently updated by react
- Let only root next.js layout handle html, body tags, not child layouts
This commit is contained in:
Debanjum
2025-04-11 16:12:03 +05:30
committed by GitHub
11 changed files with 90 additions and 214 deletions

View File

@@ -1,8 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noto_sans, noto_sans_arabic } from "@/app/fonts";
import "../globals.css"; import "../globals.css";
import { ContentSecurityPolicy } from "../common/layoutHelper";
import { ThemeProvider } from "../components/providers/themeProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Agents", title: "Khoj AI - Agents",
@@ -34,33 +31,10 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return <>{children}</>;
<html lang="en" className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<ContentSecurityPolicy />
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
} }

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import "../globals.css"; import "../globals.css";
import { ContentSecurityPolicy } from "../common/layoutHelper";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Automations", title: "Khoj AI - Automations",
@@ -34,18 +33,15 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html> <>
<ContentSecurityPolicy />
<body>
{children} {children}
<Toaster /> <Toaster />
</body> </>
</html>
); );
} }

View File

@@ -1,8 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noto_sans, noto_sans_arabic } from "@/app/fonts";
import "../globals.css"; import "../globals.css";
import { ContentSecurityPolicy } from "../common/layoutHelper";
import { ThemeProvider } from "../components/providers/themeProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Chat", title: "Khoj AI - Chat",
@@ -34,38 +31,19 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}> <>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<ContentSecurityPolicy />
<body>
<ThemeProvider>
{children} {children}
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`, __html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`,
}} }}
/> />
</ThemeProvider> </>
</body>
</html>
); );
} }

View File

@@ -28,8 +28,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
interface ModelSelectorProps extends PopoverProps { interface ModelSelectorProps extends PopoverProps {
onSelect: (model: ModelOptions, userModification: boolean) => void; onSelect: (model: ModelOptions) => void;
selectedModel?: string;
disabled?: boolean; disabled?: boolean;
initialModel?: string; initialModel?: string;
} }
@@ -49,9 +48,8 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
setModels(userConfig.chat_model_options); setModels(userConfig.chat_model_options);
if (!props.initialModel) { if (!props.initialModel) {
const selectedChatModelOption = userConfig.chat_model_options.find(model => model.id === userConfig.selected_chat_model_config); const selectedChatModelOption = userConfig.chat_model_options.find(model => model.id === userConfig.selected_chat_model_config);
if (!selectedChatModelOption) { if (!selectedChatModelOption && userConfig.chat_model_options.length > 0) {
setSelectedModel(userConfig.chat_model_options[0]); setSelectedModel(userConfig.chat_model_options[0]);
return;
} else { } else {
setSelectedModel(selectedChatModelOption); setSelectedModel(selectedChatModelOption);
} }
@@ -63,29 +61,10 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
}, [userConfig, props.initialModel, isLoadingUserConfig]); }, [userConfig, props.initialModel, isLoadingUserConfig]);
useEffect(() => { useEffect(() => {
if (props.selectedModel && selectedModel && props.selectedModel !== selectedModel.name) { if (selectedModel && userConfig) {
const model = models.find(model => model.name === props.selectedModel); props.onSelect(selectedModel);
setSelectedModel(model);
} }
else if (props.selectedModel === null && userConfig) { }, [selectedModel, userConfig, props.onSelect]);
const selectedChatModelOption = userConfig.chat_model_options.find(model => model.id === userConfig.selected_chat_model_config);
if (!selectedChatModelOption) {
props.onSelect(userConfig.chat_model_options[0], false);
return;
} else {
props.onSelect(selectedChatModelOption, false);
}
}
}, [props.selectedModel, models]);
useEffect(() => {
if (selectedModel) {
const userModification = selectedModel.id !== userConfig?.selected_chat_model_config;
if (props.selectedModel !== selectedModel.name) {
props.onSelect(selectedModel, userModification);
}
}
}, [selectedModel]);
if (isLoadingUserConfig) { if (isLoadingUserConfig) {
return ( return (

View File

@@ -28,10 +28,6 @@ interface ChatResponse {
response: ChatHistoryData; response: ChatHistoryData;
} }
interface ChatHistory {
[key: string]: string;
}
interface ChatHistoryProps { interface ChatHistoryProps {
conversationId: string; conversationId: string;
setTitle: (title: string) => void; setTitle: (title: string) => void;
@@ -368,7 +364,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
{data && {data &&
data.chat && data.chat &&
data.chat.map((chatMessage, index) => ( data.chat.map((chatMessage, index) => (
<> <React.Fragment key={`chatMessage-${index}`}>
{chatMessage.trainOfThought && chatMessage.by === "khoj" && ( {chatMessage.trainOfThought && chatMessage.by === "khoj" && (
<TrainOfThoughtComponent <TrainOfThoughtComponent
trainOfThought={chatMessage.trainOfThought?.map( trainOfThought={chatMessage.trainOfThought?.map(
@@ -403,7 +399,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
onDeleteMessage={handleDeleteMessage} onDeleteMessage={handleDeleteMessage}
conversationId={props.conversationId} conversationId={props.conversationId}
/> />
</> </React.Fragment>
))} ))}
{props.incomingMessages && {props.incomingMessages &&
props.incomingMessages.map((message, index) => { props.incomingMessages.map((message, index) => {

View File

@@ -311,12 +311,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
isLoading: authenticationLoading, isLoading: authenticationLoading,
} = useAuthenticatedData(); } = useAuthenticatedData();
const [customPrompt, setCustomPrompt] = useState<string | undefined>(""); const [customPrompt, setCustomPrompt] = useState<string | undefined>();
const [selectedModel, setSelectedModel] = useState<string | undefined>(); const [selectedModel, setSelectedModel] = useState<string | undefined>();
const [inputTools, setInputTools] = useState<string[] | undefined>(); const [inputTools, setInputTools] = useState<string[] | undefined>();
const [outputModes, setOutputModes] = useState<string[] | undefined>(); const [outputModes, setOutputModes] = useState<string[] | undefined>();
const [hasModified, setHasModified] = useState<boolean>(false); const [hasModified, setHasModified] = useState<boolean>(false);
const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(agentData?.slug.toLowerCase() === "khoj" ? true : false); const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(!agentData || agentData?.slug.toLowerCase() === "khoj");
const [displayInputTools, setDisplayInputTools] = useState<string[] | undefined>();
const [displayOutputModes, setDisplayOutputModes] = useState<string[] | undefined>();
const [isSaving, setIsSaving] = useState<boolean>(false); const [isSaving, setIsSaving] = useState<boolean>(false);
@@ -325,12 +327,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
function setupAgentData() { function setupAgentData() {
if (agentData) { if (agentData) {
setInputTools(agentData.input_tools); setInputTools(agentData.input_tools);
if (agentData.input_tools === undefined || agentData.input_tools.length === 0) { setDisplayInputTools(agentData.input_tools);
setInputTools(agentConfigurationOptions?.input_tools ? Object.keys(agentConfigurationOptions.input_tools) : []); if (agentData.input_tools === undefined) {
setDisplayInputTools(agentConfigurationOptions?.input_tools ? Object.keys(agentConfigurationOptions.input_tools) : []);
} }
setOutputModes(agentData.output_modes); setOutputModes(agentData.output_modes);
if (agentData.output_modes === undefined || agentData.output_modes.length === 0) { setDisplayOutputModes(agentData.output_modes);
setOutputModes(agentConfigurationOptions?.output_modes ? Object.keys(agentConfigurationOptions.output_modes) : []); if (agentData.output_modes === undefined) {
setDisplayOutputModes(agentConfigurationOptions?.output_modes ? Object.keys(agentConfigurationOptions.output_modes) : []);
} }
if (agentData.name.toLowerCase() === "khoj" || agentData.is_hidden === true) { if (agentData.name.toLowerCase() === "khoj" || agentData.is_hidden === true) {
@@ -351,16 +355,30 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
useEffect(() => { useEffect(() => {
setupAgentData(); setupAgentData();
setHasModified(false);
}, [agentData]); }, [agentData]);
// Track changes to the model, prompt, input tools and output modes fields
useEffect(() => {
if (!agentData || agentDataLoading) return; // Don't compare until data is loaded
const modelChanged = !!selectedModel && selectedModel !== agentData.chat_model;
const promptChanged = !!customPrompt && customPrompt !== agentData.persona;
// Order independent check to ensure input tools or output modes haven't been changed.
const toolsChanged = JSON.stringify(inputTools?.sort() || []) !== JSON.stringify(agentData.input_tools?.sort());
const modesChanged = JSON.stringify(outputModes?.sort() || []) !== JSON.stringify(agentData.output_modes?.sort());
setHasModified(modelChanged || promptChanged || toolsChanged || modesChanged);
// Add agentDataLoading to dependencies to ensure it runs after loading finishes
}, [selectedModel, customPrompt, inputTools, outputModes, agentData, agentDataLoading]);
function isValueChecked(value: string, existingSelections: string[]): boolean { function isValueChecked(value: string, existingSelections: string[]): boolean {
return existingSelections.includes(value); return existingSelections.includes(value);
} }
function handleCheckToggle(value: string, existingSelections: string[]): string[] { function handleCheckToggle(value: string, existingSelections: string[]): string[] {
setHasModified(true);
if (existingSelections.includes(value)) { if (existingSelections.includes(value)) {
return existingSelections.filter((v) => v !== value); return existingSelections.filter((v) => v !== value);
} }
@@ -370,7 +388,6 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
function handleCustomPromptChange(value: string) { function handleCustomPromptChange(value: string) {
setCustomPrompt(value); setCustomPrompt(value);
setHasModified(true);
} }
function handleSave() { function handleSave() {
@@ -430,11 +447,8 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
setHasModified(false); setHasModified(false);
} }
function handleModelSelect(model: string, userModification: boolean = true) { function handleModelSelect(model: string) {
setSelectedModel(model); setSelectedModel(model);
if (userModification) {
setHasModified(true);
}
} }
return ( return (
@@ -488,7 +502,7 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<SidebarMenuItem className="list-none"> <SidebarMenuItem className="list-none">
<Textarea <Textarea
className="w-full h-32 resize-none hover:resize-y" className="w-full h-32 resize-none hover:resize-y"
value={customPrompt} value={customPrompt || ""}
onChange={(e) => handleCustomPromptChange(e.target.value)} onChange={(e) => handleCustomPromptChange(e.target.value)}
readOnly={!isEditable} readOnly={!isEditable}
disabled={!isEditable} /> disabled={!isEditable} />
@@ -514,9 +528,8 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<SidebarMenuItem key={"model"} className="list-none"> <SidebarMenuItem key={"model"} className="list-none">
<ModelSelector <ModelSelector
disabled={!isEditable || !isSubscribed} disabled={!isEditable || !isSubscribed}
onSelect={(model, userModification) => handleModelSelect(model.name, userModification)} onSelect={(model) => handleModelSelect(model.name)}
initialModel={isDefaultAgent ? undefined : agentData?.chat_model} initialModel={isDefaultAgent ? undefined : agentData?.chat_model}
selectedModel={selectedModel}
/> />
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
@@ -551,8 +564,12 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<Checkbox <Checkbox
id={key} id={key}
className={`${isEditable ? "cursor-pointer" : ""}`} className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(key, inputTools ?? [])} checked={isValueChecked(key, displayInputTools ?? [])}
onCheckedChange={() => setInputTools(handleCheckToggle(key, inputTools ?? []))} onCheckedChange={() => {
let updatedInputTools = handleCheckToggle(key, displayInputTools ?? [])
setInputTools(updatedInputTools);
setDisplayInputTools(updatedInputTools);
}}
disabled={!isEditable} disabled={!isEditable}
> >
{key} {key}
@@ -584,8 +601,12 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<Checkbox <Checkbox
id={key} id={key}
className={`${isEditable ? "cursor-pointer" : ""}`} className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(key, outputModes ?? [])} checked={isValueChecked(key, displayOutputModes ?? [])}
onCheckedChange={() => setOutputModes(handleCheckToggle(key, outputModes ?? []))} onCheckedChange={() => {
let updatedOutputModes = handleCheckToggle(key, displayOutputModes ?? [])
setOutputModes(updatedOutputModes);
setDisplayOutputModes(updatedOutputModes);
}}
disabled={!isEditable} disabled={!isEditable}
> >
{key} {key}
@@ -649,8 +670,8 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<AgentCreationForm <AgentCreationForm
customPrompt={customPrompt} customPrompt={customPrompt}
selectedModel={selectedModel} selectedModel={selectedModel}
inputTools={inputTools ?? []} inputTools={displayInputTools ?? []}
outputModes={outputModes ?? []} outputModes={displayOutputModes ?? []}
/> />
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>

View File

@@ -48,7 +48,11 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}> <html
lang="en"
className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}
suppressHydrationWarning
>
<head> <head>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -65,9 +69,7 @@ export default function RootLayout({
</head> </head>
<ContentSecurityPolicy /> <ContentSecurityPolicy />
<body> <body>
<ThemeProvider> <ThemeProvider>{children}</ThemeProvider>
{children}
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,9 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "../globals.css"; import "../globals.css";
import { ContentSecurityPolicy } from "../common/layoutHelper";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "../components/providers/themeProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Search", title: "Khoj AI - Search",
@@ -29,33 +26,10 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return <>{children}</>;
<html>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<ContentSecurityPolicy />
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
} }

View File

@@ -1,10 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noto_sans, noto_sans_arabic } from "@/app/fonts";
import "../globals.css"; import "../globals.css";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ContentSecurityPolicy } from "../common/layoutHelper";
import { ChatwootWidget } from "../components/chatWoot/ChatwootWidget"; import { ChatwootWidget } from "../components/chatWoot/ChatwootWidget";
import { ThemeProvider } from "../components/providers/themeProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Settings", title: "Khoj AI - Settings",
@@ -34,35 +31,16 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}> <>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<ContentSecurityPolicy />
<body>
<ThemeProvider>
{children} {children}
<Toaster /> <Toaster />
<ChatwootWidget /> <ChatwootWidget />
</ThemeProvider> </>
</body>
</html>
); );
} }

View File

@@ -746,7 +746,7 @@ export default function SettingsView() {
<Input <Input
type="text" type="text"
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
value={name} value={name || ""}
className="w-full border border-gray-300 rounded-lg p-4 py-6" className="w-full border border-gray-300 rounded-lg p-4 py-6"
/> />
</CardContent> </CardContent>

View File

@@ -1,8 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { noto_sans, noto_sans_arabic } from "@/app/fonts";
import "../../globals.css"; import "../../globals.css";
import { ContentSecurityPolicy } from "@/app/common/layoutHelper";
import { ThemeProvider } from "@/app/components/providers/themeProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Khoj AI - Ask Anything", title: "Khoj AI - Ask Anything",
@@ -34,38 +31,19 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function ChildLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${noto_sans.variable} ${noto_sans_arabic.variable}`}> <>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<ContentSecurityPolicy />
<body>
<ThemeProvider>
{children} {children}
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`, __html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`,
}} }}
/> />
</ThemeProvider> </>
</body>
</html>
); );
} }