1
0
Fork 0
mirror of https://github.com/khoj-ai/khoj.git synced 2025-02-18 23:54:20 +00:00

Show all agents, smart sorted, in carousel on home screen of web app ()

## Overview
Allow quickly selecting, switching agents from agents pane on home page of web app

## Details
- Show all agents in carousel on home screen agent pane of web app
- Smart Sort
  1. Pin default agent as first for ease of access
  2. Show used agents by MRU for ease of access
  3. Shuffle unused agents for discoverability
- Select most recently used agent to chat with by default
- Push smart sort logic down to API
  - Common logic can be reused across clients
  - Agent sort was previously done in web app
- Focus on chat input on agent select
- Double click agent on home page to open edit agent card on agents page
This commit is contained in:
Debanjum 2024-10-22 21:18:17 -07:00 committed by GitHub
commit c81e708833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1546 additions and 1380 deletions
src
interface/web/app
khoj/routers

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ import "katex/dist/katex.min.css";
import { Context, OnlineContext, StreamMessage } from "../components/chatMessage/chatMessage";
import { useIPLocationData, useIsMobileWidth, welcomeConsole } from "../common/utils";
import ChatInputArea, { ChatOptions } from "../components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "../components/chatInputArea/chatInputArea";
import { useAuthenticatedData } from "../common/auth";
import { AgentData } from "../agents/page";

View file

@ -93,3 +93,15 @@ export function useUserConfig(detailed: boolean = false) {
return { userConfig, isLoadingUserConfig };
}
export function isUserSubscribed(userConfig: UserConfig | null): boolean {
return (
(userConfig?.subscription_state &&
[
SubscriptionStates.SUBSCRIBED.valueOf(),
SubscriptionStates.TRIAL.valueOf(),
SubscriptionStates.UNSUBSCRIBED.valueOf(),
].includes(userConfig.subscription_state)) ||
false
);
}

View file

@ -70,3 +70,19 @@ export function useIsMobileWidth() {
return isMobileWidth;
}
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -0,0 +1,20 @@
.agentPersonality p {
white-space: inherit;
overflow: hidden;
height: 77px;
line-height: 1.5;
}
div.agentPersonality {
text-align: left;
grid-column: span 3;
overflow: hidden;
}
button.infoButton {
border: none;
background-color: transparent !important;
text-align: left;
font-family: inherit;
font-size: medium;
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import styles from "./chatInputArea.module.css";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, forwardRef } from "react";
import DOMPurify from "dompurify";
import "katex/dist/katex.min.css";
@ -52,7 +52,7 @@ interface ChatInputProps {
agentColor?: string;
}
export default function ChatInputArea(props: ChatInputProps) {
export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((props, ref) => {
const [message, setMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
@ -264,9 +264,9 @@ export default function ChatInputArea(props: ChatInputProps) {
}
}, [recording, mediaRecorder]);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const chatInputRef = ref as React.MutableRefObject<HTMLTextAreaElement>;
useEffect(() => {
if (!chatInputRef.current) return;
if (!chatInputRef?.current) return;
chatInputRef.current.style.height = "auto";
chatInputRef.current.style.height =
Math.max(chatInputRef.current.scrollHeight - 24, 64) + "px";
@ -522,4 +522,6 @@ export default function ChatInputArea(props: ChatInputProps) {
</div>
</>
);
}
});
ChatInputArea.displayName = "ChatInputArea";

View file

@ -3,26 +3,33 @@ import "./globals.css";
import styles from "./page.module.css";
import "katex/dist/katex.min.css";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import Image from "next/image";
import { ArrowCounterClockwise } from "@phosphor-icons/react";
import { Card, CardTitle } from "@/components/ui/card";
import SuggestionCard from "@/app/components/suggestions/suggestionCard";
import SidePanel from "@/app/components/sidePanel/chatHistorySidePanel";
import Loading from "@/app/components/loading/loading";
import ChatInputArea, { ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { Suggestion, suggestionsData } from "@/app/components/suggestions/suggestionsData";
import LoginPrompt from "@/app/components/loginPrompt/loginPrompt";
import { useAuthenticatedData, UserConfig, useUserConfig } from "@/app/common/auth";
import {
isUserSubscribed,
useAuthenticatedData,
UserConfig,
useUserConfig,
} from "@/app/common/auth";
import { convertColorToBorderClass } from "@/app/common/colorUtils";
import { getIconFromIconName } from "@/app/common/iconUtils";
import { AgentData } from "@/app/agents/page";
import { createNewConversation } from "./common/chatFunctions";
import { useIsMobileWidth } from "./common/utils";
import { useSearchParams } from "next/navigation";
import { useDebounce, useIsMobileWidth } from "./common/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { AgentCard } from "@/app/components/agentCard/agentCard";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
@ -48,10 +55,15 @@ function ChatBodyData(props: ChatBodyDataProps) {
const [processingMessage, setProcessingMessage] = useState(false);
const [greeting, setGreeting] = useState("");
const [shuffledOptions, setShuffledOptions] = useState<Suggestion[]>([]);
const [hoveredAgent, setHoveredAgent] = useState<string | null>(null);
const debouncedHoveredAgent = useDebounce(hoveredAgent, 500);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<string | null>("khoj");
const [agentIcons, setAgentIcons] = useState<JSX.Element[]>([]);
const [agents, setAgents] = useState<AgentData[]>([]);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const queryParam = searchParams.get("q");
@ -61,6 +73,12 @@ function ChatBodyData(props: ChatBodyDataProps) {
}
}, [queryParam]);
useEffect(() => {
if (debouncedHoveredAgent) {
setIsPopoverOpen(true);
}
}, [debouncedHoveredAgent]);
const onConversationIdChange = props.onConversationIdChange;
const agentsFetcher = () =>
@ -72,6 +90,10 @@ function ChatBodyData(props: ChatBodyDataProps) {
revalidateOnFocus: false,
});
const openAgentEditCard = (agentSlug: string) => {
router.push(`/agents?agent=${agentSlug}`);
};
function shuffleAndSetOptions() {
const shuffled = FisherYatesShuffle(suggestionsData);
setShuffledOptions(shuffled.slice(0, 3));
@ -108,22 +130,13 @@ function ChatBodyData(props: ChatBodyDataProps) {
}, [props.chatOptionsData]);
useEffect(() => {
const nSlice = props.isMobileWidth ? 2 : 4;
const shuffledAgents = agentsData ? [...agentsData].sort(() => 0.5 - Math.random()) : [];
const agents = agentsData ? [agentsData[0]] : []; // Always add the first/default agent.
shuffledAgents.slice(0, nSlice - 1).forEach((agent) => {
if (!agents.find((a) => a.slug === agent.slug)) {
agents.push(agent);
}
});
const agents = (agentsData || []).filter((agent) => agent !== null && agent !== undefined);
setAgents(agents);
// set the selected agent to the most recently used agent, first agent is always khoj
setSelectedAgent(agents.length > 1 ? agents[1].slug : "khoj");
//generate colored icons for the selected agents
const agentIcons = agents
.filter((agent) => agent !== null && agent !== undefined)
.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
// generate colored icons for the available agents
const agentIcons = agents.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
setAgentIcons(agentIcons);
}, [agentsData, props.isMobileWidth]);
@ -157,6 +170,20 @@ function ChatBodyData(props: ChatBodyDataProps) {
}
}, [selectedAgent, message, processingMessage, onConversationIdChange]);
// Close the agent detail hover card when scroll on agent pane
useEffect(() => {
const scrollAreaSelector = "[data-radix-scroll-area-viewport]";
const scrollAreaEl = document.querySelector<HTMLElement>(scrollAreaSelector);
const handleScroll = () => {
setHoveredAgent(null);
setIsPopoverOpen(false);
};
scrollAreaEl?.addEventListener("scroll", handleScroll);
return () => scrollAreaEl?.removeEventListener("scroll", handleScroll);
}, []);
function fillArea(link: string, type: string, prompt: string) {
if (!link) {
let message_str = "";
@ -195,34 +222,73 @@ function ChatBodyData(props: ChatBodyDataProps) {
</h1>
</div>
{!props.isMobileWidth && (
<div className="flex pb-6 gap-2 items-center justify-center">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${
selectedAgent === agents[index].slug
? convertColorToBorderClass(agents[index].color)
: "border-stone-100 dark:border-neutral-700 text-muted-foreground"
}
hover:cursor-pointer rounded-lg px-2 py-2`}
>
<CardTitle
className="text-center text-md font-medium flex justify-center items-center"
onClick={() => setSelectedAgent(agents[index].slug)}
<ScrollArea className="w-full max-w-[600px] mx-auto">
<div className="flex pb-2 gap-2 items-center justify-center">
{agents.map((agent, index) => (
<Popover
key={`${index}-${agent.slug}`}
open={isPopoverOpen && debouncedHoveredAgent === agent.slug}
onOpenChange={(open) => {
if (!open) {
setHoveredAgent(null);
setIsPopoverOpen(false);
}
}}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
<Card
className="border-none shadow-none flex justify-center items-center hover:cursor-pointer"
onClick={() => (window.location.href = "/agents")}
>
<CardTitle className="text-center text-md font-normal flex justify-center items-center px-1.5 py-2">
See All
</CardTitle>
</Card>
</div>
<PopoverTrigger asChild>
<Card
className={`${
selectedAgent === agent.slug
? convertColorToBorderClass(agent.color)
: "border-stone-100 dark:border-neutral-700 text-muted-foreground"
}
hover:cursor-pointer rounded-lg px-2 py-2`}
onDoubleClick={() => openAgentEditCard(agent.slug)}
onClick={() => {
setSelectedAgent(agent.slug);
chatInputRef.current?.focus();
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
onMouseEnter={() => setHoveredAgent(agent.slug)}
onMouseLeave={() => {
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
>
<CardTitle className="text-center text-md font-medium flex justify-center items-center whitespace-nowrap">
{agentIcons[index]} {agent.name}
</CardTitle>
</Card>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0 border-none bg-transparent shadow-none"
onMouseLeave={() => {
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
>
<AgentCard
data={agent}
userProfile={null}
isMobileWidth={props.isMobileWidth || false}
showChatButton={false}
editCard={false}
filesOptions={[]}
selectedChatModelOption=""
agentSlug=""
isSubscribed={isUserSubscribed(props.userConfig)}
setAgentChangeTriggered={() => {}}
modelOptions={[]}
inputToolOptions={{}}
outputModeOptions={{}}
/>
</PopoverContent>
</Popover>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
</div>
<div className={`mx-auto ${props.isMobileWidth ? "w-full" : "w-fit max-w-screen-md"}`}>
@ -239,6 +305,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
conversationId={null}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
)}
@ -286,31 +353,30 @@ function ChatBodyData(props: ChatBodyDataProps) {
<div
className={`${styles.inputBox} pt-1 shadow-[0_-20px_25px_-5px_rgba(0,0,0,0.1)] dark:bg-neutral-700 bg-background align-middle items-center justify-center pb-3 mx-1 rounded-t-2xl rounded-b-none`}
>
<div className="flex gap-2 items-center justify-left pt-1 pb-2 px-12">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${selectedAgent === agents[index].slug ? convertColorToBorderClass(agents[index].color) : "border-muted text-muted-foreground"} hover:cursor-pointer`}
>
<CardTitle
className="text-center text-xs font-medium flex justify-center items-center px-1.5 py-1"
onClick={() => setSelectedAgent(agents[index].slug)}
<ScrollArea className="w-full max-w-[85vw]">
<div className="flex gap-2 items-center justify-left pt-1 pb-2 px-12">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${selectedAgent === agents[index].slug ? convertColorToBorderClass(agents[index].color) : "border-muted text-muted-foreground"} hover:cursor-pointer`}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
<Card
className="border-none shadow-none flex justify-center items-center hover:cursor-pointer"
onClick={() => (window.location.href = "/agents")}
>
<CardTitle
className={`text-center ${props.isMobileWidth ? "text-xs" : "text-md"} font-normal flex justify-center items-center px-1.5 py-2`}
>
See All
</CardTitle>
</Card>
</div>
<CardTitle
className="text-center text-xs font-medium flex justify-center items-center px-1.5 py-1"
onDoubleClick={() =>
openAgentEditCard(agents[index].slug)
}
onClick={() => {
setSelectedAgent(agents[index].slug);
chatInputRef.current?.focus();
}}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
@ -320,6 +386,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
conversationId={null}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
</>

View file

@ -13,7 +13,7 @@ import "katex/dist/katex.min.css";
import { useIPLocationData, useIsMobileWidth, welcomeConsole } from "../../common/utils";
import { useAuthenticatedData } from "@/app/common/auth";
import ChatInputArea, { ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { StreamMessage } from "@/app/components/chatMessage/chatMessage";
import { processMessageChunk } from "@/app/common/chatFunctions";
import { AgentData } from "@/app/agents/page";

View file

@ -1,5 +1,7 @@
import json
import logging
import random
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from asgiref.sync import sync_to_async
@ -9,8 +11,8 @@ from fastapi.responses import Response
from pydantic import BaseModel
from starlette.authentication import requires
from khoj.database.adapters import AgentAdapters
from khoj.database.models import Agent, KhojUser
from khoj.database.adapters import AgentAdapters, ConversationAdapters
from khoj.database.models import Agent, Conversation, KhojUser
from khoj.routers.helpers import CommonQueryParams, acheck_if_safe_prompt
from khoj.utils.helpers import (
ConversationCommand,
@ -45,30 +47,49 @@ async def all_agents(
) -> Response:
user: KhojUser = request.user.object if request.user.is_authenticated else None
agents = await AgentAdapters.aget_all_accessible_agents(user)
default_agent = await AgentAdapters.aget_default_agent()
default_agent_packet = None
agents_packet = list()
for agent in agents:
files = agent.fileobject_set.all()
file_names = [file.file_name for file in files]
agents_packet.append(
{
"slug": agent.slug,
"name": agent.name,
"persona": agent.personality,
"creator": agent.creator.username if agent.creator else None,
"managed_by_admin": agent.managed_by_admin,
"color": agent.style_color,
"icon": agent.style_icon,
"privacy_level": agent.privacy_level,
"chat_model": agent.chat_model.chat_model,
"files": file_names,
"input_tools": agent.input_tools,
"output_modes": agent.output_modes,
}
)
agent_packet = {
"slug": agent.slug,
"name": agent.name,
"persona": agent.personality,
"creator": agent.creator.username if agent.creator else None,
"managed_by_admin": agent.managed_by_admin,
"color": agent.style_color,
"icon": agent.style_icon,
"privacy_level": agent.privacy_level,
"chat_model": agent.chat_model.chat_model,
"files": file_names,
"input_tools": agent.input_tools,
"output_modes": agent.output_modes,
}
if agent.slug == default_agent.slug:
default_agent_packet = agent_packet
else:
agents_packet.append(agent_packet)
# Load recent conversation sessions
min_date = datetime.min.replace(tzinfo=timezone.utc)
two_weeks_ago = datetime.today() - timedelta(weeks=2)
conversations = []
if user:
conversations = await sync_to_async(list[Conversation])(
ConversationAdapters.get_conversation_sessions(user, request.user.client_app)
.filter(updated_at__gte=two_weeks_ago)
.order_by("-updated_at")[:50]
)
conversation_times = {conv.agent.slug: conv.updated_at for conv in conversations if conv.agent}
# Put default agent first, then sort by mru and finally shuffle unused randomly
random.shuffle(agents_packet)
agents_packet.sort(key=lambda x: conversation_times.get(x["slug"]) or min_date, reverse=True)
if default_agent_packet:
agents_packet.insert(0, default_agent_packet)
# Make sure that the agent named 'khoj' is first in the list. Everything else is sorted by name.
agents_packet.sort(key=lambda x: x["name"])
agents_packet.sort(key=lambda x: x["slug"] == "khoj", reverse=True)
return Response(content=json.dumps(agents_packet), media_type="application/json", status_code=200)