Merge pull request #1017 from khoj-ai/features/update-home-page

- Rather than chunky generic cards, make the suggested actions more action oriented, around the problem a user might want to solve. Give them follow-up options. Design still in progress.
This commit is contained in:
sabaimran
2024-12-24 12:12:37 -08:00
committed by GitHub
16 changed files with 715 additions and 987 deletions

View File

@@ -33,6 +33,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { AppSidebar } from "../components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "../components/logo/khojLogo";
import { DialogTitle } from "@radix-ui/react-dialog";
export interface AgentData {
slug: string;
@@ -145,7 +146,9 @@ function CreateAgentCard(props: CreateAgentCardProps) {
"lg:max-w-screen-lg py-4 overflow-y-scroll h-full md:h-4/6 rounded-lg flex flex-col"
}
>
<DialogHeader>Create Agent</DialogHeader>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
{!props.userProfile && showLoginPrompt && (
<LoginPrompt
onOpenChange={setShowLoginPrompt}

View File

@@ -168,6 +168,12 @@ const iconMap: IconMap = {
Broadcast: (color: string, width: string, height: string) => (
<Broadcast className={`${width} ${height} ${color} mr-2`} />
),
Image: (color: string, width: string, height: string) => (
<Image className={`${width} ${height} ${color} mr-2`} />
),
File: (color: string, width: string, height: string) => (
<File className={`${width} ${height} ${color} mr-2`} />
),
};
export function getIconForSlashCommand(command: string, customClassName: string | null = null) {

View File

@@ -92,6 +92,7 @@ import ShareLink from "@/app/components/shareLink/shareLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
export interface AgentData {
slug: string;
@@ -453,7 +454,9 @@ export function AgentCard(props: AgentCardProps) {
<DialogHeader>
<div className="flex items-center">
{getIconFromIconName(props.data.icon, props.data.color)}
<p className="font-bold text-lg">{props.data.name}</p>
<p className="font-bold text-lg">
<DialogTitle>{props.data.name}</DialogTitle>
</p>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-scroll text-neutral-500 dark:text-white">
@@ -649,6 +652,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
};
const handleSubmit = (values: any) => {
console.log("Submitting", values);
props.onSubmit(values);
setIsSaving(true);
};
@@ -937,7 +941,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
</FormDescription>
<FormControl>
<Textarea
className="dark:bg-muted"
className="dark:bg-muted focus:outline-none focus-visible:border-orange-500 focus-visible:border-2"
placeholder="You are an excellent biologist, at the top of your field in marine biology."
{...field}
/>
@@ -967,7 +971,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
? `${field.value.length} files selected`
: "Select files"}
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent className="m-1">
<Command>
<AlertDialog open={warning !== null || error != null}>
<AlertDialogContent>
@@ -1040,6 +1044,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
<CommandItem
value={file}
key={file}
className="break-all"
onSelect={() => {
const currentFiles =
props.form.getValues("files") ||
@@ -1257,83 +1262,88 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
return (
<Form {...props.form}>
<form
onSubmit={props.form.handleSubmit(handleSubmit)}
className="space-y-6 pb-4 h-full flex flex-col justify-between"
>
<Tabs defaultValue="basic" value={formGroups[currentStep].tabName}>
<TabsList className="grid grid-cols-2 md:grid-cols-4 gap-2 h-fit">
<ScrollArea className="h-full">
<form
onSubmit={props.form.handleSubmit(handleSubmit)}
className="space-y-6 pb-4 px-4 h-full flex flex-col justify-between"
>
<Tabs defaultValue="basic" value={formGroups[currentStep].tabName}>
<TabsList className="grid grid-cols-2 md:grid-cols-4 gap-2 h-fit">
{formGroups.map((group) => (
<TabsTrigger
key={group.tabName}
value={group.tabName}
className={`text-center ${areRequiredFieldsCompletedForCurrentStep(group) ? "" : "text-red-500"}`}
onClick={() =>
setCurrentStep(
formGroups.findIndex(
(g) => g.tabName === group.tabName,
),
)
}
>
{group.label}{" "}
{!areRequiredFieldsCompletedForCurrentStep(group) && "*"}
</TabsTrigger>
))}
</TabsList>
{formGroups.map((group) => (
<TabsTrigger
key={group.tabName}
value={group.tabName}
className={`text-center ${areRequiredFieldsCompletedForCurrentStep(group) ? "" : "text-red-500"}`}
onClick={() =>
setCurrentStep(
formGroups.findIndex((g) => g.tabName === group.tabName),
)
}
>
{group.label}{" "}
{!areRequiredFieldsCompletedForCurrentStep(group) && "*"}
</TabsTrigger>
<TabsContent key={group.tabName} value={group.tabName}>
{group.fields.map((field) => renderFormField(field.name))}
</TabsContent>
))}
</TabsList>
{formGroups.map((group) => (
<TabsContent key={group.tabName} value={group.tabName}>
{group.fields.map((field) => renderFormField(field.name))}
</TabsContent>
))}
</Tabs>
<div className="flex justify-between mt-4">
</Tabs>
{props.errors && (
<Alert className="bg-secondary border-none my-4">
<AlertDescription className="flex items-center gap-1">
<ShieldWarning
weight="fill"
className="h-4 w-4 text-yellow-400 inline"
/>
<span>{props.errors}</span>
</AlertDescription>
</Alert>
)}
</form>
</ScrollArea>
<div className="flex justify-between mt-1 left-0 right-0 bg-background p-1">
<Button
type="button"
variant={"outline"}
onClick={handlePrevious}
disabled={currentStep === 0}
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
{currentStep < formGroups.length - 1 ? (
<Button
type="button"
variant={"outline"}
onClick={handlePrevious}
disabled={currentStep === 0}
onClick={handleNext}
disabled={
!areRequiredFieldsCompletedForCurrentStep(formGroups[currentStep])
}
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
variant={"outline"}
disabled={isSaving}
onClick={props.form.handleSubmit(handleSubmit)}
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
>
<FloppyDisk className="h-4 w-4 mr-2" />
{isSaving ? "Booting..." : "Save"}
</Button>
{currentStep < formGroups.length - 1 ? (
<Button
type="button"
variant={"outline"}
onClick={handleNext}
disabled={
!areRequiredFieldsCompletedForCurrentStep(formGroups[currentStep])
}
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
variant={"outline"}
disabled={isSaving}
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
>
<FloppyDisk className="h-4 w-4 mr-2" />
{isSaving ? "Booting..." : "Save"}
</Button>
)}
</div>
{props.errors && (
<Alert className="bg-secondary border-none my-4">
<AlertDescription className="flex items-center gap-1">
<ShieldWarning
weight="fill"
className="h-4 w-4 text-yellow-400 inline"
/>
<span>{props.errors}</span>
</AlertDescription>
</Alert>
)}
</form>
</div>
</Form>
);
}

View File

@@ -420,9 +420,6 @@ interface SessionsAndFilesProps {
function SessionsAndFiles(props: SessionsAndFilesProps) {
return (
<div>
{props.data && props.data.length > 5 && (
<ChatSessionsModal data={props.organizedData} sideBarOpen={props.sideBarOpen} />
)}
{props.sideBarOpen && (
<ScrollArea>
<ScrollAreaScrollbar
@@ -461,6 +458,9 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
</div>
</ScrollArea>
)}
{props.data && props.data.length > 5 && (
<ChatSessionsModal data={props.organizedData} sideBarOpen={props.sideBarOpen} />
)}
</div>
);
}
@@ -786,7 +786,7 @@ function ChatSessionsModal({ data, sideBarOpen }: ChatSessionsModalProps) {
>
<span className="flex items-center gap-1">
<ChatsCircle className="inline h-4 w-4 mr-1" />
{sideBarOpen ? "Find Conversation" : ""}
{sideBarOpen ? "All Conversations" : ""}
</span>
</DialogTrigger>
<DialogContent>

View File

@@ -65,6 +65,12 @@ export interface AttachedFileText {
size: number;
}
export enum ChatInputFocus {
MESSAGE = "message",
FILE = "file",
RESEARCH = "research",
}
interface ChatInputProps {
sendMessage: (message: string) => void;
sendImage: (image: string) => void;
@@ -77,11 +83,15 @@ interface ChatInputProps {
agentColor?: string;
isResearchModeEnabled?: boolean;
setTriggeredAbort: (value: boolean) => void;
prefillMessage?: string;
focus?: ChatInputFocus;
}
export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((props, ref) => {
const [message, setMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInputButtonRef = useRef<HTMLButtonElement>(null);
const researchModeRef = useRef<HTMLButtonElement>(null);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -125,6 +135,22 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
}
}, [uploading]);
useEffect(() => {
if (props.prefillMessage === undefined) return;
setMessage(props.prefillMessage);
chatInputRef?.current?.focus();
}, [props.prefillMessage]);
useEffect(() => {
if (props.focus === ChatInputFocus.MESSAGE) {
chatInputRef?.current?.focus();
} else if (props.focus === ChatInputFocus.FILE) {
fileInputButtonRef.current?.focus();
} else if (props.focus === ChatInputFocus.RESEARCH) {
researchModeRef.current?.focus();
}
}, [props.focus]);
useEffect(() => {
async function fetchImageData() {
if (imagePaths.length > 0) {
@@ -625,14 +651,24 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
/>
<div className="flex items-center">
<Button
variant={"ghost"}
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={handleFileButtonClick}
>
<Paperclip className="w-8 h-8" />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={handleFileButtonClick}
ref={fileInputButtonRef}
>
<Paperclip className="w-8 h-8" />
</Button>
</TooltipTrigger>
<TooltipContent>
Attach a PDF, plain text file, or image
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-grow flex flex-col w-full gap-1.5 relative">
<Textarea
@@ -732,6 +768,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
variant="ghost"
className="float-right justify-center gap-1 flex items-center p-1.5 mr-2 h-fit"
disabled={props.sendDisabled || !props.isLoggedIn}
ref={researchModeRef}
onClick={() => {
setUseResearchMode(!useResearchMode);
chatInputRef?.current?.focus();

View File

@@ -299,6 +299,7 @@ function EmailSignInContext({
className="p-6 w-[300px] mx-auto rounded-lg"
disabled={checkEmail}
value={email}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleMagicLinkSignIn();

View File

@@ -104,7 +104,7 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
<Avatar
className={`${sideBarIsOpen ? "h-8 w-8" : "h-6 w-6"} border-2 ${userData.is_active ? "border-yellow-500" : "border-stone-700 dark:border-stone-300"}`}
>
<AvatarImage src={userData?.photo} alt="user profile" />
<AvatarImage src={userData.photo} alt="user profile" />
<AvatarFallback className="bg-transparent hover:bg-muted">
{userData.username[0].toUpperCase()}
</AvatarFallback>

View File

@@ -1,25 +1,32 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription } from "@/components/ui/card";
import styles from "./suggestions.module.css";
import { converColorToBgGradient } from "@/app/common/colorUtils";
import { convertSuggestionTitleToIconClass } from "./suggestionsData";
import { MagicWand, XCircle } from "@phosphor-icons/react";
interface SuggestionCardProps {
interface StepOneSuggestionCardProps {
title: string;
body: string;
link: string;
color: string;
}
export default function SuggestionCard(data: SuggestionCardProps) {
const cardClassName = `${styles.card} md:w-full md:h-fit sm:w-full h-fit md:w-[200px] md:h-[180px] cursor-pointer md:p-2`;
interface StepOneSuggestionRevertCardProps extends StepOneSuggestionCardProps {
onClick: () => void;
}
interface StepTwoSuggestionCardProps {
prompt: string;
}
export function StepOneSuggestionCard(data: StepOneSuggestionCardProps) {
const cardClassName = `${styles.card} md:w-full md:h-fit sm:w-full h-fit md:w-[200px] cursor-pointer md:p-2 animate-fade-in-up`;
const descriptionClassName = `${styles.text} dark:text-white`;
const cardContent = (
<Card className={cardClassName}>
<div className="flex w-full">
<CardContent className="m-0 p-2 w-full">
<CardContent className="m-0 p-2 w-full flex flex-row">
{convertSuggestionTitleToIconClass(data.title, data.color.toLowerCase())}
<CardDescription
className={`${descriptionClassName} sm:line-clamp-2 md:line-clamp-4 pt-1 break-words whitespace-pre-wrap max-w-full`}
@@ -31,11 +38,48 @@ export default function SuggestionCard(data: SuggestionCardProps) {
</Card>
);
return data.link ? (
<a href={data.link} className="no-underline">
{cardContent}
</a>
) : (
cardContent
return cardContent;
}
export function StepTwoSuggestionCard(data: StepTwoSuggestionCardProps) {
const cardClassName = `${styles.card} md:h-fit sm:w-full h-fit cursor-pointer md:p-2`;
return (
<Card className={cardClassName}>
<div className="flex w-full items-center">
<CardContent className="m-0 p-1 w-full flex flex-row items-center">
<MagicWand
weight="thin"
className="w-4 h-4 text-muted-foreground inline-flex mr-1 text-opacity-40"
/>
<CardDescription
className={`sm:line-clamp-2 md:line-clamp-4 break-words whitespace-pre-wrap max-w-full text-sm text-wrap text-black dark:text-white`}
>
{data.prompt}
</CardDescription>
</CardContent>
</div>
</Card>
);
}
export function StepOneSuggestionRevertCard(data: StepOneSuggestionRevertCardProps) {
const cardClassName = `${styles.card} md:w-full md:h-fit sm:w-full h-fit md:w-fit cursor-pointer m-2 md:p-1 animate-fade-in-up border-opacity-50 shadow-none`;
const descriptionClassName = `${styles.text} dark:text-white`;
return (
<Card className={cardClassName} onClick={data.onClick}>
<div className="flex w-full h-full items-center justify-center">
<CardContent className="m-0 p-2 w-full flex flex-row items-center justify-center">
{convertSuggestionTitleToIconClass(data.title, data.color.toLowerCase())}
<CardDescription
className={`${descriptionClassName} sm:line-clamp-2 md:line-clamp-4 pt-1 break-words whitespace-pre-wrap max-w-full text-center`}
>
{data.body}
</CardDescription>
<XCircle className="w-6 h-6 text-muted-foreground inline-flex ml-1" />
</CardContent>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -98,16 +98,24 @@ div.homeGreetings {
div.chatBox {
padding: 0;
height: 100vh;
height: 100%;
}
div.chatLayout {
gap: 0;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
display: flex;
height: 100%;
}
div.homeGreetings {
grid-template-rows: auto 1fr;
display: flex;
flex-direction: column;
}
div.inputBox {
margin-bottom: 0;
height: fit-content;
align-items: flex-end;
margin-top: auto;
}
}

View File

@@ -5,17 +5,28 @@ import "katex/dist/katex.min.css";
import React, { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { ArrowCounterClockwise } from "@phosphor-icons/react";
import { ArrowsVertical } from "@phosphor-icons/react";
import { Card, CardTitle } from "@/components/ui/card";
import SuggestionCard from "@/app/components/suggestions/suggestionCard";
import {
StepOneSuggestionCard,
StepOneSuggestionRevertCard,
StepTwoSuggestionCard,
} from "@/app/components/suggestions/suggestionCard";
import Loading from "@/app/components/loading/loading";
import {
AttachedFileText,
ChatInputArea,
ChatInputFocus,
ChatOptions,
} from "@/app/components/chatInputArea/chatInputArea";
import { Suggestion, suggestionsData } from "@/app/components/suggestions/suggestionsData";
import {
StepOneSuggestion,
stepOneSuggestions,
StepTwoSuggestion,
getStepTwoSuggestions,
SuggestionType,
} from "@/app/components/suggestions/suggestionsData";
import LoginPrompt from "@/app/components/loginPrompt/loginPrompt";
import {
@@ -49,20 +60,21 @@ interface ChatBodyDataProps {
isLoadingUserConfig: boolean;
}
function FisherYatesShuffle(array: any[]) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function ChatBodyData(props: ChatBodyDataProps) {
const [message, setMessage] = useState("");
const [prefillMessage, setPrefillMessage] = useState("");
const [chatInputFocus, setChatInputFocus] = useState<ChatInputFocus>(ChatInputFocus.MESSAGE);
const [images, setImages] = useState<string[]>([]);
const [processingMessage, setProcessingMessage] = useState(false);
const [greeting, setGreeting] = useState("");
const [shuffledOptions, setShuffledOptions] = useState<Suggestion[]>([]);
const [stepOneSuggestionOptions, setStepOneSuggestionOptions] = useState<StepOneSuggestion[]>(
stepOneSuggestions.slice(0, 3),
);
const [stepTwoSuggestionOptions, setStepTwoSuggestionOptions] = useState<StepTwoSuggestion[]>(
[],
);
const [selectedStepOneSuggestion, setSelectedStepOneSuggestion] =
useState<StepOneSuggestion | null>(null);
const [hoveredAgent, setHoveredAgent] = useState<string | null>(null);
const debouncedHoveredAgent = useDebounce(hoveredAgent, 500);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -102,11 +114,6 @@ function ChatBodyData(props: ChatBodyDataProps) {
router.push(`/agents?agent=${agentSlug}`);
};
function shuffleAndSetOptions() {
const shuffled = FisherYatesShuffle(suggestionsData);
setShuffledOptions(shuffled.slice(0, 3));
}
useEffect(() => {
if (props.isLoadingUserConfig) return;
@@ -131,12 +138,6 @@ function ChatBodyData(props: ChatBodyDataProps) {
setGreeting(greeting);
}, [props.isLoadingUserConfig, props.userConfig]);
useEffect(() => {
if (props.chatOptionsData) {
shuffleAndSetOptions();
}
}, [props.chatOptionsData]);
useEffect(() => {
const agents = (agentsData || []).filter((agent) => agent !== null && agent !== undefined);
setAgents(agents);
@@ -146,10 +147,10 @@ function ChatBodyData(props: ChatBodyDataProps) {
// generate colored icons for the available agents
const agentIcons = agents.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
setAgentIcons(agentIcons);
}, [agentsData, props.isMobileWidth]);
}, [agentsData]);
function shuffleSuggestionsCards() {
shuffleAndSetOptions();
function showAllSuggestionsCards() {
setStepOneSuggestionOptions(stepOneSuggestions);
}
useEffect(() => {
@@ -193,27 +194,12 @@ function ChatBodyData(props: ChatBodyDataProps) {
return () => scrollAreaEl?.removeEventListener("scroll", handleScroll);
}, []);
function fillArea(link: string, type: string, prompt: string) {
if (!link) {
let message_str = "";
prompt = prompt.charAt(0).toLowerCase() + prompt.slice(1);
if (type === "Online Search") {
message_str = "/online " + prompt;
} else if (type === "Paint") {
message_str = "/image " + prompt;
} else {
message_str = prompt;
}
// Get the textarea element
const message_area = document.getElementById("message") as HTMLTextAreaElement;
if (message_area) {
// Update the value directly
message_area.value = message_str;
setMessage(message_str);
}
}
function clickStepOneSuggestion(suggestion: StepOneSuggestion) {
setPrefillMessage(suggestion.intent);
const stepTwoSuggestions = getStepTwoSuggestions(suggestion.type);
setSelectedStepOneSuggestion(suggestion);
setStepTwoSuggestionOptions(stepTwoSuggestions);
setChatInputFocus(suggestion.focus);
}
return (
@@ -306,13 +292,15 @@ function ChatBodyData(props: ChatBodyDataProps) {
</ScrollArea>
)}
</div>
<div className={`mx-auto ${props.isMobileWidth ? "w-full" : "w-fit max-w-screen-md"}`}>
<div className={`mx-auto ${props.isMobileWidth ? "w-full" : "w-full max-w-screen-md"}`}>
{!props.isMobileWidth && (
<div
className={`w-full ${styles.inputBox} shadow-lg bg-background align-middle items-center justify-center px-3 py-1 dark:bg-neutral-700 border-stone-100 dark:border-none dark:shadow-none rounded-2xl`}
>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
prefillMessage={prefillMessage}
focus={chatInputFocus}
sendMessage={(message) => setMessage(message)}
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
sendDisabled={processingMessage}
@@ -326,44 +314,77 @@ function ChatBodyData(props: ChatBodyDataProps) {
/>
</div>
)}
<div
className={`${styles.suggestions} w-full ${props.isMobileWidth ? "grid" : "flex flex-row"} justify-center items-center`}
>
{shuffledOptions.map((suggestion, index) => (
<div
key={`${suggestion.type} ${suggestion.description}`}
onClick={(event) => {
if (props.isLoggedIn) {
fillArea(
suggestion.link,
suggestion.type,
suggestion.description,
);
} else {
event.preventDefault();
event.stopPropagation();
setShowLoginPrompt(true);
}
}}
>
<SuggestionCard
key={suggestion.type + Math.random()}
title={suggestion.type}
body={suggestion.description}
link={suggestion.link}
color={suggestion.color}
/>
</div>
))}
</div>
<div className="flex items-center justify-center margin-auto">
<button
onClick={shuffleSuggestionsCards}
className="m-2 p-1.5 rounded-lg dark:hover:bg-[var(--background-color)] hover:bg-stone-100 border border-stone-100 text-sm text-stone-500 dark:text-stone-300 dark:border-neutral-700"
{stepTwoSuggestionOptions.length == 0 && (
<div
className={`${styles.suggestions} w-full ${props.isMobileWidth ? (stepOneSuggestions.length > 3 ? "grid grid-cols-2" : "grid grid-cols-3") : "grid grid-cols-3"} "justify-center items-center"`}
>
More Ideas <ArrowCounterClockwise className="h-4 w-4 inline" />
</button>
</div>
{stepOneSuggestionOptions.map((suggestion, index) => (
<div
key={`${suggestion.type} ${suggestion.actionTagline}`}
onClick={(event) => {
if (props.isLoggedIn) {
clickStepOneSuggestion(suggestion);
} else {
event.preventDefault();
event.stopPropagation();
setShowLoginPrompt(true);
}
}}
>
<StepOneSuggestionCard
key={suggestion.type + Math.random()}
title={suggestion.type}
body={suggestion.actionTagline}
color={suggestion.color}
/>
</div>
))}
</div>
)}
{stepTwoSuggestionOptions.length == 0 &&
stepOneSuggestionOptions.length < stepOneSuggestions.length && (
<div className="flex items-center justify-center margin-auto">
<button
onClick={showAllSuggestionsCards}
className="m-2 p-1.5 rounded-lg dark:hover:bg-[var(--background-color)] hover:bg-stone-100 border border-stone-100 text-sm text-stone-500 dark:text-stone-300 dark:border-neutral-700"
>
Show All <ArrowsVertical className="h-4 w-4 inline" />
</button>
</div>
)}
{selectedStepOneSuggestion && (
<StepOneSuggestionRevertCard
title={selectedStepOneSuggestion.type}
body={selectedStepOneSuggestion.actionTagline}
color={selectedStepOneSuggestion.color}
onClick={() => {
setPrefillMessage("");
setSelectedStepOneSuggestion(null);
setStepTwoSuggestionOptions([]);
setChatInputFocus(ChatInputFocus.MESSAGE);
}}
/>
)}
{stepTwoSuggestionOptions.length > 0 && (
<div
className={`w-full ${props.isMobileWidth ? "grid" : "grid grid-cols-1"} justify-center items-center gap-2 p-2`}
>
{stepTwoSuggestionOptions.map((suggestion, index) => (
<div
key={`${suggestion.prompt} ${index}`}
className={`w-full cursor-pointer animate-fade-in-up`}
onClick={(event) => {
setMessage(suggestion.prompt);
}}
>
<StepTwoSuggestionCard
key={suggestion.prompt}
prompt={suggestion.prompt}
/>
</div>
))}
</div>
)}
</div>
{props.isMobileWidth && (
<>

View File

@@ -515,7 +515,7 @@ export default function SettingsView() {
const title = "Settings";
const cardClassName =
"w-full lg:w-1/3 grid grid-flow-column border border-gray-300 shadow-md rounded-lg border dark:border-none dark:bg-muted border-opacity-50";
"w-full lg:w-5/12 grid grid-flow-column border border-gray-300 shadow-md rounded-lg border dark:border-none dark:bg-muted border-opacity-50";
useEffect(() => {
setUserConfig(initialUserConfig);

View File

@@ -8,8 +8,6 @@ div.page {
div.contentBody {
display: grid;
margin: auto;
margin-left: 20vw;
margin-top: 1rem;
}
div.phoneInput {

View File

@@ -133,11 +133,16 @@ const config = {
opacity: "0",
},
},
fadeInUp: {
"0%": { opacity: "0", transform: "translateY(20px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
"fade-in-up": "fadeInUp 0.3s ease-out",
},
},
},

View File

@@ -3,6 +3,7 @@ import datetime
import logging
import os
from typing import Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import requests
from fastapi import APIRouter, Depends
@@ -13,6 +14,7 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
from starlette.status import HTTP_302_FOUND
from khoj.app.settings import DISABLE_HTTPS
from khoj.database.adapters import (
acreate_khoj_token,
aget_or_create_user_by_email,
@@ -28,7 +30,6 @@ from khoj.routers.helpers import (
update_telemetry_state,
)
from khoj.utils import state
from khoj.utils.helpers import in_debug_mode
logger = logging.getLogger(__name__)
@@ -204,23 +205,33 @@ async def auth_post(request: Request):
@auth_router.get("/redirect")
async def auth(request: Request):
next_url = get_next_url(request)
for q in request.query_params:
if q in ["code", "state", "scope", "authuser", "prompt", "session_state", "access_type"]:
continue
if q != "next":
next_url += f"&{q}={request.query_params[q]}"
next_url_path = get_next_url(request)
code = request.query_params.get("code")
# Add query params from request, excluding OAuth params to next URL
oauth_params = {"code", "state", "scope", "authuser", "prompt", "session_state", "access_type", "next"}
query_params = {param: value for param, value in request.query_params.items() if param not in oauth_params}
# 1. Construct the full redirect URI including domain
# Rebuild next URL with updated query params
parsed_next_url_path = urlparse(next_url_path)
next_url = urlunparse(
(
parsed_next_url_path.scheme,
parsed_next_url_path.netloc,
parsed_next_url_path.path,
parsed_next_url_path.params,
urlencode(query_params, doseq=True),
parsed_next_url_path.fragment,
)
)
# Construct the full redirect URI including domain
base_url = str(request.base_url).rstrip("/")
if not in_debug_mode():
if not DISABLE_HTTPS:
base_url = base_url.replace("http://", "https://")
redirect_uri = f"{base_url}{request.app.url_path_for('auth')}"
# Build the payload for the token request
code = request.query_params.get("code")
payload = {
"code": code,
"client_id": os.environ["GOOGLE_CLIENT_ID"],
@@ -229,12 +240,14 @@ async def auth(request: Request):
"grant_type": "authorization_code",
}
# Request the token from Google
verified_data = requests.post(
"https://oauth2.googleapis.com/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
# Validate the OAuth response
if verified_data.status_code != 200:
logger.error(f"Token request failed: {verified_data.text}")
try:
@@ -245,20 +258,24 @@ async def auth(request: Request):
verified_data.raise_for_status()
credential = verified_data.json().get("id_token")
if not credential:
logger.error("Missing id_token in OAuth response")
return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
# Validate the OAuth token
try:
idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
except OAuthError as error:
return HTMLResponse(f"<h1>{error.error}</h1>")
# Get or create the authenticated user in the database
khoj_user = await get_or_create_user(idinfo)
# Set the user session if the user is authenticated
if khoj_user:
request.session["user"] = dict(idinfo)
# Send a welcome email to new users
if datetime.timedelta(minutes=3) > (datetime.datetime.now(datetime.timezone.utc) - khoj_user.date_joined):
asyncio.create_task(send_welcome_email(idinfo["name"], idinfo["email"]))
update_telemetry_state(
@@ -269,6 +286,7 @@ async def auth(request: Request):
)
logger.log(logging.INFO, f"🥳 New User Created: {khoj_user.uuid}")
# Redirect the user to the next URL
return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)

View File

@@ -325,7 +325,7 @@ async def acheck_if_safe_prompt(system_prompt: str, user: KhojUser = None, lax:
response = response.strip()
try:
response = json.loads(response)
response = clean_json(response)
is_safe = response.get("safe", "True") == "True"
if not is_safe:
reason = response.get("reason", "")