mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-09 13:25:11 +00:00
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:
@@ -33,6 +33,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
|
|||||||
import { AppSidebar } from "../components/appSidebar/appSidebar";
|
import { AppSidebar } from "../components/appSidebar/appSidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { KhojLogoType } from "../components/logo/khojLogo";
|
import { KhojLogoType } from "../components/logo/khojLogo";
|
||||||
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
|
||||||
export interface AgentData {
|
export interface AgentData {
|
||||||
slug: string;
|
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"
|
"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 && (
|
{!props.userProfile && showLoginPrompt && (
|
||||||
<LoginPrompt
|
<LoginPrompt
|
||||||
onOpenChange={setShowLoginPrompt}
|
onOpenChange={setShowLoginPrompt}
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ const iconMap: IconMap = {
|
|||||||
Broadcast: (color: string, width: string, height: string) => (
|
Broadcast: (color: string, width: string, height: string) => (
|
||||||
<Broadcast className={`${width} ${height} ${color} mr-2`} />
|
<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) {
|
export function getIconForSlashCommand(command: string, customClassName: string | null = null) {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import ShareLink from "@/app/components/shareLink/shareLink";
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
export interface AgentData {
|
export interface AgentData {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -453,7 +454,9 @@ export function AgentCard(props: AgentCardProps) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{getIconFromIconName(props.data.icon, props.data.color)}
|
{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>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-y-scroll text-neutral-500 dark:text-white">
|
<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) => {
|
const handleSubmit = (values: any) => {
|
||||||
|
console.log("Submitting", values);
|
||||||
props.onSubmit(values);
|
props.onSubmit(values);
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
};
|
};
|
||||||
@@ -937,7 +941,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<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."
|
placeholder="You are an excellent biologist, at the top of your field in marine biology."
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -967,7 +971,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
|||||||
? `${field.value.length} files selected`
|
? `${field.value.length} files selected`
|
||||||
: "Select files"}
|
: "Select files"}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent className="m-1">
|
||||||
<Command>
|
<Command>
|
||||||
<AlertDialog open={warning !== null || error != null}>
|
<AlertDialog open={warning !== null || error != null}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -1040,6 +1044,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
value={file}
|
value={file}
|
||||||
key={file}
|
key={file}
|
||||||
|
className="break-all"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const currentFiles =
|
const currentFiles =
|
||||||
props.form.getValues("files") ||
|
props.form.getValues("files") ||
|
||||||
@@ -1257,83 +1262,88 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...props.form}>
|
<Form {...props.form}>
|
||||||
<form
|
<ScrollArea className="h-full">
|
||||||
onSubmit={props.form.handleSubmit(handleSubmit)}
|
<form
|
||||||
className="space-y-6 pb-4 h-full flex flex-col justify-between"
|
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">
|
<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) => (
|
{formGroups.map((group) => (
|
||||||
<TabsTrigger
|
<TabsContent key={group.tabName} value={group.tabName}>
|
||||||
key={group.tabName}
|
{group.fields.map((field) => renderFormField(field.name))}
|
||||||
value={group.tabName}
|
</TabsContent>
|
||||||
className={`text-center ${areRequiredFieldsCompletedForCurrentStep(group) ? "" : "text-red-500"}`}
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentStep(
|
|
||||||
formGroups.findIndex((g) => g.tabName === group.tabName),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{group.label}{" "}
|
|
||||||
{!areRequiredFieldsCompletedForCurrentStep(group) && "*"}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</Tabs>
|
||||||
{formGroups.map((group) => (
|
|
||||||
<TabsContent key={group.tabName} value={group.tabName}>
|
{props.errors && (
|
||||||
{group.fields.map((field) => renderFormField(field.name))}
|
<Alert className="bg-secondary border-none my-4">
|
||||||
</TabsContent>
|
<AlertDescription className="flex items-center gap-1">
|
||||||
))}
|
<ShieldWarning
|
||||||
</Tabs>
|
weight="fill"
|
||||||
<div className="flex justify-between mt-4">
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
onClick={handlePrevious}
|
onClick={handleNext}
|
||||||
disabled={currentStep === 0}
|
disabled={
|
||||||
|
!areRequiredFieldsCompletedForCurrentStep(formGroups[currentStep])
|
||||||
|
}
|
||||||
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
|
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Continue
|
||||||
Previous
|
<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>
|
</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>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,9 +420,6 @@ interface SessionsAndFilesProps {
|
|||||||
function SessionsAndFiles(props: SessionsAndFilesProps) {
|
function SessionsAndFiles(props: SessionsAndFilesProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{props.data && props.data.length > 5 && (
|
|
||||||
<ChatSessionsModal data={props.organizedData} sideBarOpen={props.sideBarOpen} />
|
|
||||||
)}
|
|
||||||
{props.sideBarOpen && (
|
{props.sideBarOpen && (
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<ScrollAreaScrollbar
|
<ScrollAreaScrollbar
|
||||||
@@ -461,6 +458,9 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
{props.data && props.data.length > 5 && (
|
||||||
|
<ChatSessionsModal data={props.organizedData} sideBarOpen={props.sideBarOpen} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -786,7 +786,7 @@ function ChatSessionsModal({ data, sideBarOpen }: ChatSessionsModalProps) {
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<ChatsCircle className="inline h-4 w-4 mr-1" />
|
<ChatsCircle className="inline h-4 w-4 mr-1" />
|
||||||
{sideBarOpen ? "Find Conversation" : ""}
|
{sideBarOpen ? "All Conversations" : ""}
|
||||||
</span>
|
</span>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export interface AttachedFileText {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ChatInputFocus {
|
||||||
|
MESSAGE = "message",
|
||||||
|
FILE = "file",
|
||||||
|
RESEARCH = "research",
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
sendMessage: (message: string) => void;
|
sendMessage: (message: string) => void;
|
||||||
sendImage: (image: string) => void;
|
sendImage: (image: string) => void;
|
||||||
@@ -77,11 +83,15 @@ interface ChatInputProps {
|
|||||||
agentColor?: string;
|
agentColor?: string;
|
||||||
isResearchModeEnabled?: boolean;
|
isResearchModeEnabled?: boolean;
|
||||||
setTriggeredAbort: (value: boolean) => void;
|
setTriggeredAbort: (value: boolean) => void;
|
||||||
|
prefillMessage?: string;
|
||||||
|
focus?: ChatInputFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((props, ref) => {
|
export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((props, ref) => {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileInputButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const researchModeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const [warning, setWarning] = useState<string | null>(null);
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -125,6 +135,22 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
|
|||||||
}
|
}
|
||||||
}, [uploading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
async function fetchImageData() {
|
async function fetchImageData() {
|
||||||
if (imagePaths.length > 0) {
|
if (imagePaths.length > 0) {
|
||||||
@@ -625,14 +651,24 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant={"ghost"}
|
<Tooltip>
|
||||||
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
|
<TooltipTrigger asChild>
|
||||||
disabled={props.sendDisabled || !props.isLoggedIn}
|
<Button
|
||||||
onClick={handleFileButtonClick}
|
variant={"ghost"}
|
||||||
>
|
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
|
||||||
<Paperclip className="w-8 h-8" />
|
disabled={props.sendDisabled || !props.isLoggedIn}
|
||||||
</Button>
|
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>
|
||||||
<div className="flex-grow flex flex-col w-full gap-1.5 relative">
|
<div className="flex-grow flex flex-col w-full gap-1.5 relative">
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -732,6 +768,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="float-right justify-center gap-1 flex items-center p-1.5 mr-2 h-fit"
|
className="float-right justify-center gap-1 flex items-center p-1.5 mr-2 h-fit"
|
||||||
disabled={props.sendDisabled || !props.isLoggedIn}
|
disabled={props.sendDisabled || !props.isLoggedIn}
|
||||||
|
ref={researchModeRef}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUseResearchMode(!useResearchMode);
|
setUseResearchMode(!useResearchMode);
|
||||||
chatInputRef?.current?.focus();
|
chatInputRef?.current?.focus();
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ function EmailSignInContext({
|
|||||||
className="p-6 w-[300px] mx-auto rounded-lg"
|
className="p-6 w-[300px] mx-auto rounded-lg"
|
||||||
disabled={checkEmail}
|
disabled={checkEmail}
|
||||||
value={email}
|
value={email}
|
||||||
|
autoFocus={true}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
handleMagicLinkSignIn();
|
handleMagicLinkSignIn();
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
|
|||||||
<Avatar
|
<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"}`}
|
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">
|
<AvatarFallback className="bg-transparent hover:bg-muted">
|
||||||
{userData.username[0].toUpperCase()}
|
{userData.username[0].toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
"use client";
|
"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 styles from "./suggestions.module.css";
|
||||||
import { converColorToBgGradient } from "@/app/common/colorUtils";
|
|
||||||
import { convertSuggestionTitleToIconClass } from "./suggestionsData";
|
import { convertSuggestionTitleToIconClass } from "./suggestionsData";
|
||||||
|
import { MagicWand, XCircle } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface SuggestionCardProps {
|
interface StepOneSuggestionCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
link: string;
|
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SuggestionCard(data: SuggestionCardProps) {
|
interface StepOneSuggestionRevertCardProps extends StepOneSuggestionCardProps {
|
||||||
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`;
|
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 descriptionClassName = `${styles.text} dark:text-white`;
|
||||||
|
|
||||||
const cardContent = (
|
const cardContent = (
|
||||||
<Card className={cardClassName}>
|
<Card className={cardClassName}>
|
||||||
<div className="flex w-full">
|
<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())}
|
{convertSuggestionTitleToIconClass(data.title, data.color.toLowerCase())}
|
||||||
<CardDescription
|
<CardDescription
|
||||||
className={`${descriptionClassName} sm:line-clamp-2 md:line-clamp-4 pt-1 break-words whitespace-pre-wrap max-w-full`}
|
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>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.link ? (
|
return cardContent;
|
||||||
<a href={data.link} className="no-underline">
|
}
|
||||||
{cardContent}
|
|
||||||
</a>
|
export function StepTwoSuggestionCard(data: StepTwoSuggestionCardProps) {
|
||||||
) : (
|
const cardClassName = `${styles.card} md:h-fit sm:w-full h-fit cursor-pointer md:p-2`;
|
||||||
cardContent
|
|
||||||
|
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
@@ -98,16 +98,24 @@ div.homeGreetings {
|
|||||||
|
|
||||||
div.chatBox {
|
div.chatBox {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.chatLayout {
|
div.chatLayout {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
grid-template-columns: 1fr;
|
display: flex;
|
||||||
grid-template-rows: auto 1fr;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.homeGreetings {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,28 @@ import "katex/dist/katex.min.css";
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { ArrowCounterClockwise } from "@phosphor-icons/react";
|
import { ArrowsVertical } from "@phosphor-icons/react";
|
||||||
|
|
||||||
import { Card, CardTitle } from "@/components/ui/card";
|
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 Loading from "@/app/components/loading/loading";
|
||||||
import {
|
import {
|
||||||
AttachedFileText,
|
AttachedFileText,
|
||||||
ChatInputArea,
|
ChatInputArea,
|
||||||
|
ChatInputFocus,
|
||||||
ChatOptions,
|
ChatOptions,
|
||||||
} from "@/app/components/chatInputArea/chatInputArea";
|
} 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 LoginPrompt from "@/app/components/loginPrompt/loginPrompt";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -49,20 +60,21 @@ interface ChatBodyDataProps {
|
|||||||
isLoadingUserConfig: boolean;
|
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) {
|
function ChatBodyData(props: ChatBodyDataProps) {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [prefillMessage, setPrefillMessage] = useState("");
|
||||||
|
const [chatInputFocus, setChatInputFocus] = useState<ChatInputFocus>(ChatInputFocus.MESSAGE);
|
||||||
const [images, setImages] = useState<string[]>([]);
|
const [images, setImages] = useState<string[]>([]);
|
||||||
const [processingMessage, setProcessingMessage] = useState(false);
|
const [processingMessage, setProcessingMessage] = useState(false);
|
||||||
const [greeting, setGreeting] = useState("");
|
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 [hoveredAgent, setHoveredAgent] = useState<string | null>(null);
|
||||||
const debouncedHoveredAgent = useDebounce(hoveredAgent, 500);
|
const debouncedHoveredAgent = useDebounce(hoveredAgent, 500);
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
@@ -102,11 +114,6 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
router.push(`/agents?agent=${agentSlug}`);
|
router.push(`/agents?agent=${agentSlug}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
function shuffleAndSetOptions() {
|
|
||||||
const shuffled = FisherYatesShuffle(suggestionsData);
|
|
||||||
setShuffledOptions(shuffled.slice(0, 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.isLoadingUserConfig) return;
|
if (props.isLoadingUserConfig) return;
|
||||||
|
|
||||||
@@ -131,12 +138,6 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
setGreeting(greeting);
|
setGreeting(greeting);
|
||||||
}, [props.isLoadingUserConfig, props.userConfig]);
|
}, [props.isLoadingUserConfig, props.userConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.chatOptionsData) {
|
|
||||||
shuffleAndSetOptions();
|
|
||||||
}
|
|
||||||
}, [props.chatOptionsData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const agents = (agentsData || []).filter((agent) => agent !== null && agent !== undefined);
|
const agents = (agentsData || []).filter((agent) => agent !== null && agent !== undefined);
|
||||||
setAgents(agents);
|
setAgents(agents);
|
||||||
@@ -146,10 +147,10 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
// generate colored icons for the available agents
|
// generate colored icons for the available agents
|
||||||
const agentIcons = agents.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
|
const agentIcons = agents.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
|
||||||
setAgentIcons(agentIcons);
|
setAgentIcons(agentIcons);
|
||||||
}, [agentsData, props.isMobileWidth]);
|
}, [agentsData]);
|
||||||
|
|
||||||
function shuffleSuggestionsCards() {
|
function showAllSuggestionsCards() {
|
||||||
shuffleAndSetOptions();
|
setStepOneSuggestionOptions(stepOneSuggestions);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -193,27 +194,12 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
return () => scrollAreaEl?.removeEventListener("scroll", handleScroll);
|
return () => scrollAreaEl?.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function fillArea(link: string, type: string, prompt: string) {
|
function clickStepOneSuggestion(suggestion: StepOneSuggestion) {
|
||||||
if (!link) {
|
setPrefillMessage(suggestion.intent);
|
||||||
let message_str = "";
|
const stepTwoSuggestions = getStepTwoSuggestions(suggestion.type);
|
||||||
prompt = prompt.charAt(0).toLowerCase() + prompt.slice(1);
|
setSelectedStepOneSuggestion(suggestion);
|
||||||
|
setStepTwoSuggestionOptions(stepTwoSuggestions);
|
||||||
if (type === "Online Search") {
|
setChatInputFocus(suggestion.focus);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -306,13 +292,15 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{!props.isMobileWidth && (
|
||||||
<div
|
<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`}
|
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
|
<ChatInputArea
|
||||||
isLoggedIn={props.isLoggedIn}
|
isLoggedIn={props.isLoggedIn}
|
||||||
|
prefillMessage={prefillMessage}
|
||||||
|
focus={chatInputFocus}
|
||||||
sendMessage={(message) => setMessage(message)}
|
sendMessage={(message) => setMessage(message)}
|
||||||
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
|
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
|
||||||
sendDisabled={processingMessage}
|
sendDisabled={processingMessage}
|
||||||
@@ -326,44 +314,77 @@ function ChatBodyData(props: ChatBodyDataProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
{stepTwoSuggestionOptions.length == 0 && (
|
||||||
className={`${styles.suggestions} w-full ${props.isMobileWidth ? "grid" : "flex flex-row"} justify-center items-center`}
|
<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"`}
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
More Ideas <ArrowCounterClockwise className="h-4 w-4 inline" />
|
{stepOneSuggestionOptions.map((suggestion, index) => (
|
||||||
</button>
|
<div
|
||||||
</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>
|
</div>
|
||||||
{props.isMobileWidth && (
|
{props.isMobileWidth && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ export default function SettingsView() {
|
|||||||
const title = "Settings";
|
const title = "Settings";
|
||||||
|
|
||||||
const cardClassName =
|
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(() => {
|
useEffect(() => {
|
||||||
setUserConfig(initialUserConfig);
|
setUserConfig(initialUserConfig);
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ div.page {
|
|||||||
div.contentBody {
|
div.contentBody {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-left: 20vw;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.phoneInput {
|
div.phoneInput {
|
||||||
|
|||||||
@@ -133,11 +133,16 @@ const config = {
|
|||||||
opacity: "0",
|
opacity: "0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
fadeInUp: {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(20px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
||||||
|
"fade-in-up": "fadeInUp 0.3s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -13,6 +14,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from starlette.status import HTTP_302_FOUND
|
from starlette.status import HTTP_302_FOUND
|
||||||
|
|
||||||
|
from khoj.app.settings import DISABLE_HTTPS
|
||||||
from khoj.database.adapters import (
|
from khoj.database.adapters import (
|
||||||
acreate_khoj_token,
|
acreate_khoj_token,
|
||||||
aget_or_create_user_by_email,
|
aget_or_create_user_by_email,
|
||||||
@@ -28,7 +30,6 @@ from khoj.routers.helpers import (
|
|||||||
update_telemetry_state,
|
update_telemetry_state,
|
||||||
)
|
)
|
||||||
from khoj.utils import state
|
from khoj.utils import state
|
||||||
from khoj.utils.helpers import in_debug_mode
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -204,23 +205,33 @@ async def auth_post(request: Request):
|
|||||||
|
|
||||||
@auth_router.get("/redirect")
|
@auth_router.get("/redirect")
|
||||||
async def auth(request: Request):
|
async def auth(request: Request):
|
||||||
next_url = get_next_url(request)
|
next_url_path = 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]}"
|
|
||||||
|
|
||||||
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("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
if not DISABLE_HTTPS:
|
||||||
if not in_debug_mode():
|
|
||||||
base_url = base_url.replace("http://", "https://")
|
base_url = base_url.replace("http://", "https://")
|
||||||
|
|
||||||
redirect_uri = f"{base_url}{request.app.url_path_for('auth')}"
|
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 = {
|
payload = {
|
||||||
"code": code,
|
"code": code,
|
||||||
"client_id": os.environ["GOOGLE_CLIENT_ID"],
|
"client_id": os.environ["GOOGLE_CLIENT_ID"],
|
||||||
@@ -229,12 +240,14 @@ async def auth(request: Request):
|
|||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Request the token from Google
|
||||||
verified_data = requests.post(
|
verified_data = requests.post(
|
||||||
"https://oauth2.googleapis.com/token",
|
"https://oauth2.googleapis.com/token",
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
data=payload,
|
data=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate the OAuth response
|
||||||
if verified_data.status_code != 200:
|
if verified_data.status_code != 200:
|
||||||
logger.error(f"Token request failed: {verified_data.text}")
|
logger.error(f"Token request failed: {verified_data.text}")
|
||||||
try:
|
try:
|
||||||
@@ -245,20 +258,24 @@ async def auth(request: Request):
|
|||||||
verified_data.raise_for_status()
|
verified_data.raise_for_status()
|
||||||
|
|
||||||
credential = verified_data.json().get("id_token")
|
credential = verified_data.json().get("id_token")
|
||||||
|
|
||||||
if not credential:
|
if not credential:
|
||||||
logger.error("Missing id_token in OAuth response")
|
logger.error("Missing id_token in OAuth response")
|
||||||
return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
|
return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
|
||||||
|
|
||||||
|
# Validate the OAuth token
|
||||||
try:
|
try:
|
||||||
idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
|
idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
|
||||||
except OAuthError as error:
|
except OAuthError as error:
|
||||||
return HTMLResponse(f"<h1>{error.error}</h1>")
|
return HTMLResponse(f"<h1>{error.error}</h1>")
|
||||||
|
|
||||||
|
# Get or create the authenticated user in the database
|
||||||
khoj_user = await get_or_create_user(idinfo)
|
khoj_user = await get_or_create_user(idinfo)
|
||||||
|
|
||||||
|
# Set the user session if the user is authenticated
|
||||||
if khoj_user:
|
if khoj_user:
|
||||||
request.session["user"] = dict(idinfo)
|
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):
|
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"]))
|
asyncio.create_task(send_welcome_email(idinfo["name"], idinfo["email"]))
|
||||||
update_telemetry_state(
|
update_telemetry_state(
|
||||||
@@ -269,6 +286,7 @@ async def auth(request: Request):
|
|||||||
)
|
)
|
||||||
logger.log(logging.INFO, f"🥳 New User Created: {khoj_user.uuid}")
|
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)
|
return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ async def acheck_if_safe_prompt(system_prompt: str, user: KhojUser = None, lax:
|
|||||||
|
|
||||||
response = response.strip()
|
response = response.strip()
|
||||||
try:
|
try:
|
||||||
response = json.loads(response)
|
response = clean_json(response)
|
||||||
is_safe = response.get("safe", "True") == "True"
|
is_safe = response.get("safe", "True") == "True"
|
||||||
if not is_safe:
|
if not is_safe:
|
||||||
reason = response.get("reason", "")
|
reason = response.get("reason", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user