mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-06 13:22:12 +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 { 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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,8 +8,6 @@ div.page {
|
||||
div.contentBody {
|
||||
display: grid;
|
||||
margin: auto;
|
||||
margin-left: 20vw;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
div.phoneInput {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user