Modularize code and implemenet share experience

This commit is contained in:
sabaimran 2024-07-10 23:08:16 +05:30
parent 1b4a51f4a2
commit 6f1d799759
13 changed files with 915 additions and 393 deletions

View file

@ -21,11 +21,6 @@ div.inputBox {
align-content: center;
}
div.actualInputArea {
display: grid;
grid-template-columns: auto 1fr auto auto;
}
input.inputBox {
border: none;
}

View file

@ -1,7 +1,7 @@
'use client'
import styles from './chat.module.css';
import React, { Suspense, useEffect, useRef, useState } from 'react';
import React, { Suspense, useEffect, useState } from 'react';
import SuggestionCard from '../components/suggestions/suggestionCard';
import SidePanel from '../components/sidePanel/chatHistorySidePanel';
@ -10,289 +10,16 @@ import NavMenu from '../components/navMenu/navMenu';
import { useSearchParams } from 'next/navigation'
import Loading from '../components/loading/loading';
import { handleCompiledReferences, handleImageResponse, setupWebSocket, uploadDataForIndexing } from '../common/chatFunctions';
import { Progress } from "@/components/ui/progress"
import { handleCompiledReferences, handleImageResponse, setupWebSocket } from '../common/chatFunctions';
import 'katex/dist/katex.min.css';
import {
ArrowCircleUp,
ArrowRight,
Browser,
ChatsTeardrop,
FileArrowUp,
GlobeSimple,
Gps,
Image,
Microphone,
Notebook,
Question,
Robot,
Shapes
} from '@phosphor-icons/react';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { Textarea } from "@/components/ui/textarea"
import { Button } from '@/components/ui/button';
import { StreamMessage } from '../components/chatMessage/chatMessage';
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Popover, PopoverContent } from '@/components/ui/popover';
import { PopoverTrigger } from '@radix-ui/react-popover';
import { welcomeConsole } from '../common/utils';
import ChatInputArea, { ChatOptions } from '../components/chatInputArea/chatInputArea';
import { useAuthenticatedData } from '../common/auth';
interface ChatInputProps {
sendMessage: (message: string) => void;
sendDisabled: boolean;
setUploadedFiles?: (files: string[]) => void;
conversationId?: string | null;
chatOptionsData?: ChatOptions | null;
isMobileWidth?: boolean;
}
function ChatInputArea(props: ChatInputProps) {
const [message, setMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progressValue, setProgressValue] = useState(0);
useEffect(() => {
if (!uploading) {
setProgressValue(0);
}
if (uploading) {
const interval = setInterval(() => {
setProgressValue((prev) => {
const increment = Math.floor(Math.random() * 5) + 1; // Generates a random number between 1 and 5
const nextValue = prev + increment;
return nextValue < 100 ? nextValue : 100; // Ensures progress does not exceed 100
});
}, 800);
return () => clearInterval(interval);
}
}, [uploading]);
function onSendMessage() {
props.sendMessage(message);
setMessage('');
}
function handleSlashCommandClick(command: string) {
setMessage(`/${command} `);
}
function handleFileButtonClick() {
if (!fileInputRef.current) return;
fileInputRef.current.click();
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (!event.target.files) return;
uploadDataForIndexing(
event.target.files,
setWarning,
setUploading,
setError,
props.setUploadedFiles,
props.conversationId);
}
function getIconForSlashCommand(command: string) {
if (command.includes('summarize')) {
return <Gps className='h-4 w-4 mr-2' />
}
if (command.includes('help')) {
return <Question className='h-4 w-4 mr-2' />
}
if (command.includes('automation')) {
return <Robot className='h-4 w-4 mr-2' />
}
if (command.includes('webpage')) {
return <Browser className='h-4 w-4 mr-2' />
}
if (command.includes('notes')) {
return <Notebook className='h-4 w-4 mr-2' />
}
if (command.includes('image')) {
return <Image className='h-4 w-4 mr-2' />
}
if (command.includes('default')) {
return <Shapes className='h-4 w-4 mr-2' />
}
if (command.includes('general')) {
return <ChatsTeardrop className='h-4 w-4 mr-2' />
}
if (command.includes('online')) {
return <GlobeSimple className='h-4 w-4 mr-2' />
}
return <ArrowRight className='h-4 w-4 mr-2' />
}
return (
<>
{
uploading && (
<AlertDialog
open={uploading}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Uploading data. Please wait.</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Progress
indicatorColor='bg-slate-500'
className='w-full h-2 rounded-full'
value={progressValue} />
</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setUploading(false)}>Dismiss</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)}
{
warning && (
<AlertDialog
open={warning !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data Upload Warning</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>{warning}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setWarning(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
{
error && (
<AlertDialog
open={error !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Oh no!</AlertDialogTitle>
<AlertDialogDescription>Something went wrong while uploading your data</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogDescription>{error}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setError(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
{
(message.startsWith('/') && message.split(' ').length === 1) &&
<div className='flex justify-center text-center'>
<Popover
open={message.startsWith('/')}>
<PopoverTrigger className='flex justify-center text-center'>
</PopoverTrigger>
<PopoverContent
onOpenAutoFocus={(e) => e.preventDefault()}
className={`${props.isMobileWidth ? 'w-[100vw]' : 'w-full'} rounded-md`}>
<Command className='max-w-full'>
<CommandInput placeholder="Type a command or search..." value={message} className='hidden' />
<CommandList>
<CommandEmpty>No matching commands.</CommandEmpty>
<CommandGroup heading="Agent Tools">
{props.chatOptionsData && Object.entries(props.chatOptionsData).map(([key, value]) => (
<CommandItem
key={key}
className={`text-md`}
onSelect={() => handleSlashCommandClick(key)}>
<div
className='grid grid-cols-1 gap-1'>
<div
className='font-bold flex items-center'>
{getIconForSlashCommand(key)}
/{key}
</div>
<div>
{value}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
<div className={`${styles.actualInputArea} flex items-center justify-between`}>
<input
type="file"
multiple={true}
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Button
variant={'ghost'}
className="!bg-none p-1 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}
onClick={handleFileButtonClick}>
<FileArrowUp weight='fill' />
</Button>
<div className="grid w-full gap-1.5 relative">
<Textarea
className='border-none min-h-[20px]'
placeholder="Type / to see a list of commands"
id="message"
value={message}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSendMessage();
}
}}
onChange={(e) => setMessage(e.target.value)}
disabled={props.sendDisabled} />
</div>
<Button
variant={'ghost'}
className="!bg-none p-1 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}>
<Microphone weight='fill' />
</Button>
<Button
className="bg-orange-300 hover:bg-orange-500 rounded-full p-0 h-auto text-3xl transition transform hover:-translate-y-1"
onClick={onSendMessage}
disabled={props.sendDisabled}>
<ArrowCircleUp />
</Button>
</div>
</>
)
}
interface ChatOptions {
[key: string]: string
}
const styleClassOptions = ['pink', 'blue', 'green', 'yellow', 'purple'];
interface ChatBodyDataProps {
@ -303,6 +30,7 @@ interface ChatBodyDataProps {
streamedMessages: StreamMessage[];
setUploadedFiles: (files: string[]) => void;
isMobileWidth?: boolean;
isLoggedIn: boolean;
}
@ -363,6 +91,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
</div>
<div className={`${styles.inputBox} bg-background align-middle items-center justify-center px-3`}>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
@ -386,6 +115,7 @@ export default function Chat() {
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [isMobileWidth, setIsMobileWidth] = useState(false);
const authenticatedData = useAuthenticatedData();
welcomeConsole();
@ -492,7 +222,6 @@ export default function Chat() {
rawQuery: queryToProcess || "",
}
setMessages(prevMessages => [...prevMessages, newStreamMessage]);
console.log("Messages", messages);
setProcessQuerySignal(true);
} else {
if (!chatWS) {
@ -537,13 +266,17 @@ export default function Chat() {
{title}
</title>
<div className={styles.sidePanel}>
<SidePanel webSocketConnected={chatWS !== null} conversationId={conversationId} uploadedFiles={uploadedFiles} />
<SidePanel
webSocketConnected={chatWS !== null}
conversationId={conversationId}
uploadedFiles={uploadedFiles} />
</div>
<div className={styles.chatBox}>
<NavMenu selected="Chat" title={title} />
<div className={styles.chatBoxBody}>
<Suspense fallback={<Loading />}>
<ChatBodyData
isLoggedIn={authenticatedData !== null}
streamedMessages={messages}
chatOptionsData={chatOptionsData}
setTitle={setTitle}

View file

@ -69,7 +69,7 @@ async function sendChatStream(
}
}
export const setupWebSocket = async (conversationId: string) => {
export const setupWebSocket = async (conversationId: string, initialMessage?: string) => {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = process.env.NODE_ENV === 'production' ? window.location.host : 'localhost:42110';
@ -86,6 +86,9 @@ export const setupWebSocket = async (conversationId: string) => {
chatWS.onopen = () => {
console.log('WebSocket connection established');
if (initialMessage) {
chatWS.send(initialMessage);
}
};
chatWS.onmessage = (event) => {

View file

@ -29,6 +29,7 @@ interface ChatHistoryProps {
setTitle: (title: string) => void;
incomingMessages?: StreamMessage[];
pendingMessage?: string;
publicConversationSlug?: string;
}
@ -51,7 +52,6 @@ function constructTrainOfThought(trainOfThought: string[], lastMessage: boolean,
export default function ChatHistory(props: ChatHistoryProps) {
const [data, setData] = useState<ChatHistoryData | null>(null);
const [isLoading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(0);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
@ -117,38 +117,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
setData(null);
}, [props.conversationId]);
const fetchMoreMessages = (currentPage: number) => {
if (!hasMoreMessages || fetchingData) return;
const nextPage = currentPage + 1;
fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=${10 * nextPage}`)
.then(response => response.json())
.then((chatData: ChatResponse) => {
props.setTitle(chatData.response.slug);
if (chatData && chatData.response && chatData.response.chat.length > 0) {
if (chatData.response.chat.length === data?.chat.length) {
setHasMoreMessages(false);
setFetchingData(false);
return;
}
setData(chatData.response);
setLoading(false);
if (currentPage < 2) {
scrollToBottom();
}
setFetchingData(false);
} else {
setHasMoreMessages(false);
}
})
.catch(err => {
console.error(err);
});
};
useEffect(() => {
console.log(props.incomingMessages);
if (props.incomingMessages) {
const lastMessage = props.incomingMessages[props.incomingMessages.length - 1];
if (lastMessage && !lastMessage.completed) {
@ -162,25 +132,6 @@ export default function ChatHistory(props: ChatHistoryProps) {
}, [props.incomingMessages]);
const scrollToBottom = () => {
if (chatHistoryRef.current) {
chatHistoryRef.current.scrollIntoView(false);
}
}
const isUserAtBottom = () => {
if (!chatHistoryRef.current) return false;
// NOTE: This isn't working. It always seems to return true. This is because
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current as HTMLDivElement;
const threshold = 25; // pixels from the bottom
// Considered at the bottom if within threshold pixels from the bottom
return scrollTop + clientHeight >= scrollHeight - threshold;
}
useEffect(() => {
const observer = new MutationObserver((mutationsList, observer) => {
// If the addedNodes property has one or more nodes
@ -207,9 +158,64 @@ export default function ChatHistory(props: ChatHistoryProps) {
return () => observer.disconnect();
}, []);
// if (isLoading) {
// return <Loading />;
// }
const fetchMoreMessages = (currentPage: number) => {
if (!hasMoreMessages || fetchingData) return;
const nextPage = currentPage + 1;
let conversationFetchURL = '';
if (props.conversationId) {
conversationFetchURL = `/api/chat/history?client=web&conversation_id=${props.conversationId}&n=${10 * nextPage}`;
} else if (props.publicConversationSlug) {
conversationFetchURL = `/api/chat/share/history?client=web&public_conversation_slug=${props.publicConversationSlug}&n=${10 * nextPage}`;
} else {
return;
}
fetch(conversationFetchURL)
.then(response => response.json())
.then((chatData: ChatResponse) => {
props.setTitle(chatData.response.slug);
if (chatData && chatData.response && chatData.response.chat.length > 0) {
if (chatData.response.chat.length === data?.chat.length) {
setHasMoreMessages(false);
setFetchingData(false);
return;
}
setData(chatData.response);
if (currentPage < 2) {
scrollToBottom();
}
setFetchingData(false);
} else {
setHasMoreMessages(false);
}
})
.catch(err => {
console.error(err);
});
};
const scrollToBottom = () => {
if (chatHistoryRef.current) {
chatHistoryRef.current.scrollIntoView(false);
}
}
const isUserAtBottom = () => {
if (!chatHistoryRef.current) return false;
// NOTE: This isn't working. It always seems to return true. This is because
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current as HTMLDivElement;
const threshold = 25; // pixels from the bottom
// Considered at the bottom if within threshold pixels from the bottom
return scrollTop + clientHeight >= scrollHeight - threshold;
}
function constructAgentLink() {
if (!data || !data.agent || !data.agent.slug) return `/agents`;
@ -226,6 +232,11 @@ export default function ChatHistory(props: ChatHistoryProps) {
return data.agent.name;
}
if (!props.conversationId && !props.publicConversationSlug) {
return null;
}
return (
<ScrollArea className={`h-[80vh]`}>
<div ref={ref}>

View file

@ -0,0 +1,4 @@
div.actualInputArea {
display: grid;
grid-template-columns: auto 1fr auto auto;
}

View file

@ -0,0 +1,291 @@
import styles from './chatInputArea.module.css';
import React, { useEffect, useRef, useState } from 'react';
import { uploadDataForIndexing } from '../../common/chatFunctions';
import { Progress } from "@/components/ui/progress"
import 'katex/dist/katex.min.css';
import {
ArrowCircleUp,
ArrowRight,
Browser,
ChatsTeardrop,
FileArrowUp,
GlobeSimple,
Gps,
Image,
Microphone,
Notebook,
Question,
Robot,
Shapes
} from '@phosphor-icons/react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import { Textarea } from "@/components/ui/textarea"
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Popover, PopoverContent } from '@/components/ui/popover';
import { PopoverTrigger } from '@radix-ui/react-popover';
export interface ChatOptions {
[key: string]: string
}
interface ChatInputProps {
sendMessage: (message: string) => void;
sendDisabled: boolean;
setUploadedFiles?: (files: string[]) => void;
conversationId?: string | null;
chatOptionsData?: ChatOptions | null;
isMobileWidth?: boolean;
isLoggedIn: boolean;
}
export default function ChatInputArea(props: ChatInputProps) {
const [message, setMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progressValue, setProgressValue] = useState(0);
useEffect(() => {
if (!uploading) {
setProgressValue(0);
}
if (uploading) {
const interval = setInterval(() => {
setProgressValue((prev) => {
const increment = Math.floor(Math.random() * 5) + 1; // Generates a random number between 1 and 5
const nextValue = prev + increment;
return nextValue < 100 ? nextValue : 100; // Ensures progress does not exceed 100
});
}, 800);
return () => clearInterval(interval);
}
}, [uploading]);
function onSendMessage() {
props.sendMessage(message);
setMessage('');
}
function handleSlashCommandClick(command: string) {
setMessage(`/${command} `);
}
function handleFileButtonClick() {
if (!fileInputRef.current) return;
fileInputRef.current.click();
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (!event.target.files) return;
uploadDataForIndexing(
event.target.files,
setWarning,
setUploading,
setError,
props.setUploadedFiles,
props.conversationId);
}
function getIconForSlashCommand(command: string) {
if (command.includes('summarize')) {
return <Gps className='h-4 w-4 mr-2' />
}
if (command.includes('help')) {
return <Question className='h-4 w-4 mr-2' />
}
if (command.includes('automation')) {
return <Robot className='h-4 w-4 mr-2' />
}
if (command.includes('webpage')) {
return <Browser className='h-4 w-4 mr-2' />
}
if (command.includes('notes')) {
return <Notebook className='h-4 w-4 mr-2' />
}
if (command.includes('image')) {
return <Image className='h-4 w-4 mr-2' />
}
if (command.includes('default')) {
return <Shapes className='h-4 w-4 mr-2' />
}
if (command.includes('general')) {
return <ChatsTeardrop className='h-4 w-4 mr-2' />
}
if (command.includes('online')) {
return <GlobeSimple className='h-4 w-4 mr-2' />
}
return <ArrowRight className='h-4 w-4 mr-2' />
}
return (
<>
{
uploading && (
<AlertDialog
open={uploading}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Uploading data. Please wait.</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Progress
indicatorColor='bg-slate-500'
className='w-full h-2 rounded-full'
value={progressValue} />
</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setUploading(false)}>Dismiss</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)}
{
warning && (
<AlertDialog
open={warning !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data Upload Warning</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>{warning}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setWarning(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
{
error && (
<AlertDialog
open={error !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Oh no!</AlertDialogTitle>
<AlertDialogDescription>Something went wrong while uploading your data</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogDescription>{error}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setError(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
{
(message.startsWith('/') && message.split(' ').length === 1) &&
<div className='flex justify-center text-center'>
<Popover
open={message.startsWith('/')}>
<PopoverTrigger className='flex justify-center text-center'>
</PopoverTrigger>
<PopoverContent
onOpenAutoFocus={(e) => e.preventDefault()}
className={`${props.isMobileWidth ? 'w-[100vw]' : 'w-full'} rounded-md`}>
<Command className='max-w-full'>
<CommandInput placeholder="Type a command or search..." value={message} className='hidden' />
<CommandList>
<CommandEmpty>No matching commands.</CommandEmpty>
<CommandGroup heading="Agent Tools">
{props.chatOptionsData && Object.entries(props.chatOptionsData).map(([key, value]) => (
<CommandItem
key={key}
className={`text-md`}
onSelect={() => handleSlashCommandClick(key)}>
<div
className='grid grid-cols-1 gap-1'>
<div
className='font-bold flex items-center'>
{getIconForSlashCommand(key)}
/{key}
</div>
<div>
{value}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
<div className={`${styles.actualInputArea} flex items-center justify-between`}>
<input
type="file"
multiple={true}
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Button
variant={'ghost'}
className="!bg-none p-1 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}
onClick={handleFileButtonClick}>
<FileArrowUp weight='fill' />
</Button>
<div className="grid w-full gap-1.5 relative">
<Textarea
className='border-none min-h-[20px]'
placeholder="Type / to see a list of commands"
id="message"
value={message}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSendMessage();
}
}}
onChange={(e) => setMessage(e.target.value)}
disabled={props.sendDisabled} />
</div>
<Button
variant={'ghost'}
className="!bg-none p-1 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}>
<Microphone weight='fill' />
</Button>
<Button
className="bg-orange-300 hover:bg-orange-500 rounded-full p-0 h-auto text-3xl transition transform hover:-translate-y-1"
onClick={onSendMessage}
disabled={props.sendDisabled}>
<ArrowCircleUp />
</Button>
</div>
</>
)
}

View file

@ -632,7 +632,7 @@ const fetchChatHistory = async (url: string) => {
return response.json();
};
export const useChatHistoryRecentFetchRequest = (url: string) => {
export const useChatSessionsFetchRequest = (url: string) => {
const { data, error } = useSWR<ChatHistory[]>(url, fetchChatHistory);
return {
@ -658,13 +658,13 @@ export default function SidePanel(props: SidePanelProps) {
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const { data: chatHistory } = useChatHistoryRecentFetchRequest('/api/chat/sessions');
const { data: chatSessions } = useChatSessionsFetchRequest('/api/chat/sessions');
const [isMobileWidth, setIsMobileWidth] = useState(false);
useEffect(() => {
if (chatHistory) {
setData(chatHistory);
if (chatSessions) {
setData(chatSessions);
const groupedData: GroupedChatHistory = {};
const subsetOrganizedData: GroupedChatHistory = {};
@ -672,7 +672,7 @@ export default function SidePanel(props: SidePanelProps) {
const currentDate = new Date();
chatHistory.forEach((chatHistory) => {
chatSessions.forEach((chatHistory) => {
const chatDate = new Date(chatHistory.created);
const diffTime = Math.abs(currentDate.getTime() - chatDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
@ -697,7 +697,7 @@ export default function SidePanel(props: SidePanelProps) {
setSubsetOrganizedData(subsetOrganizedData);
setOrganizedData(groupedData);
}
}, [chatHistory]);
}, [chatSessions]);
useEffect(() => {
if (window.innerWidth < 768) {
@ -722,14 +722,11 @@ export default function SidePanel(props: SidePanelProps) {
return (
<div className={`${styles.panel} ${enabled ? styles.expanded : styles.collapsed}`}>
<div className="flex items-start justify-between">
<Image src="khoj-logo.svg"
<Image src="/khoj-logo.svg"
alt="logo"
width={40}
height={40}
/>
{/* <button className={styles.button} onClick={() => setEnabled(!enabled)}>
{enabled ? <ArrowLeft className="h-4 w-4" /> : <ArrowRight className="h-4 w-4 mx-2" />}
</button> */}
{
isMobileWidth ?
<Drawer>

View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Noto_Sans } from "next/font/google";
import "../../../globals.css";
const inter = Noto_Sans({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Khoj AI - Chat",
description: "Use this page to view a chat with Khoj AI.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<meta httpEquiv="Content-Security-Policy"
content="default-src 'self' https://assets.khoj.dev;
script-src 'self' https://assets.khoj.dev 'unsafe-inline' 'unsafe-eval';
connect-src 'self' https://ipapi.co/json ws://localhost:42110;
style-src 'self' https://assets.khoj.dev 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://*.khoj.dev https://*.googleusercontent.com https://*.google.com/ https://*.gstatic.com;
font-src 'self' https://assets.khoj.dev https://fonts.gstatic.com;
child-src 'none';
object-src 'none';"></meta>
<body className={inter.className}>
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,312 @@
'use client'
import styles from './sharedChat.module.css';
import React, { Suspense, useEffect, useRef, useState } from 'react';
import SidePanel from '../../../components/sidePanel/chatHistorySidePanel';
import ChatHistory from '../../../components/chatHistory/chatHistory';
import NavMenu from '../../../components/navMenu/navMenu';
import Loading from '../../../components/loading/loading';
import 'katex/dist/katex.min.css';
import { welcomeConsole } from '../../../common/utils';
import { useAuthenticatedData } from '@/app/common/auth';
import ChatInputArea, { ChatOptions } from '@/app/components/chatInputArea/chatInputArea';
import { StreamMessage } from '@/app/components/chatMessage/chatMessage';
import { handleCompiledReferences, handleImageResponse, setupWebSocket } from '@/app/common/chatFunctions';
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
setTitle: (title: string) => void;
setUploadedFiles: (files: string[]) => void;
isMobileWidth?: boolean;
publicConversationSlug: string;
streamedMessages: StreamMessage[];
isLoggedIn: boolean;
conversationId?: string;
setQueryToProcess: (query: string) => void;
}
function ChatBodyData(props: ChatBodyDataProps) {
const [message, setMessage] = useState('');
const [processingMessage, setProcessingMessage] = useState(false);
if (!props.publicConversationSlug && !props.conversationId) {
return (
<div className={styles.suggestions}>
Whoops, nothing to see here!
</div>
);
}
useEffect(() => {
if (message) {
setProcessingMessage(true);
props.setQueryToProcess(message);
}
}, [message]);
useEffect(() => {
console.log("Streamed messages", props.streamedMessages);
if (props.streamedMessages &&
props.streamedMessages.length > 0 &&
props.streamedMessages[props.streamedMessages.length - 1].completed) {
setProcessingMessage(false);
} else {
setMessage('');
}
}, [props.streamedMessages]);
return (
<>
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory
publicConversationSlug={props.publicConversationSlug}
conversationId={props.conversationId || ''}
setTitle={props.setTitle}
pendingMessage={processingMessage ? message : ''}
incomingMessages={props.streamedMessages} />
</div>
<div className={`${styles.inputBox} bg-background align-middle items-center justify-center px-3`}>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendDisabled={!props.isLoggedIn || processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={props.conversationId}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles} />
</div>
</>
);
}
export default function SharedChat({ params }: { params: { slug: string } }) {
const [chatOptionsData, setChatOptionsData] = useState<ChatOptions | null>(null);
const [isLoading, setLoading] = useState(true);
const [title, setTitle] = useState('Khoj AI - Chat');
const [conversationId, setConversationID] = useState<string | undefined>(undefined);
const [chatWS, setChatWS] = useState<WebSocket | null>(null);
const [messages, setMessages] = useState<StreamMessage[]>([]);
const [queryToProcess, setQueryToProcess] = useState<string>('');
const [processQuerySignal, setProcessQuerySignal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [isMobileWidth, setIsMobileWidth] = useState(false);
const authenticatedData = useAuthenticatedData();
welcomeConsole();
const handleWebSocketMessage = (event: MessageEvent) => {
let chunk = event.data;
let currentMessage = messages.find(message => !message.completed);
if (!currentMessage) {
console.error("No current message found");
return;
}
// Process WebSocket streamed data
if (chunk === "start_llm_response") {
console.log("Started streaming", new Date());
} else if (chunk === "end_llm_response") {
currentMessage.completed = true;
} else {
// Get the current message
// Process and update state with the new message
if (chunk.includes("application/json")) {
chunk = JSON.parse(chunk);
}
const contentType = chunk["content-type"];
if (contentType === "application/json") {
try {
if (chunk.image || chunk.detail) {
let responseWithReference = handleImageResponse(chunk);
console.log("Image response", responseWithReference);
if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response;
if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online;
if (responseWithReference.context) currentMessage.context = responseWithReference.context;
} else if (chunk.type == "status") {
currentMessage.trainOfThought.push(chunk.message);
} else if (chunk.type == "rate_limit") {
console.log("Rate limit message", chunk);
currentMessage.rawResponse = chunk.message;
} else {
console.log("any message", chunk);
}
} catch (error) {
console.error("Error processing message", error);
currentMessage.completed = true;
} finally {
// no-op
}
} else {
// Update the current message with the new chunk
if (chunk && chunk.includes("### compiled references:")) {
let responseWithReference = handleCompiledReferences(chunk, "");
currentMessage.rawResponse += responseWithReference.response;
if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response;
if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online;
if (responseWithReference.context) currentMessage.context = responseWithReference.context;
} else {
// If the chunk is not a JSON object, just display it as is
currentMessage.rawResponse += chunk;
}
}
};
// Update the state with the new message, currentMessage
setMessages([...messages]);
}
useEffect(() => {
fetch('/api/chat/options')
.then(response => response.json())
.then((data: ChatOptions) => {
setLoading(false);
// Render chat options, if any
if (data) {
setChatOptionsData(data);
}
})
.catch(err => {
console.error(err);
return;
});
setIsMobileWidth(window.innerWidth < 786);
window.addEventListener('resize', () => {
setIsMobileWidth(window.innerWidth < 786);
});
}, []);
useEffect(() => {
if (queryToProcess && !conversationId) {
fetch(`/api/chat/share/fork?public_conversation_slug=${params.slug}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
setConversationID(data.conversation_id);
})
.catch(err => {
console.error(err);
return;
});
return;
}
if (chatWS && queryToProcess) {
// Add a new object to the state
const newStreamMessage: StreamMessage = {
rawResponse: "",
trainOfThought: [],
context: [],
onlineContext: {},
completed: false,
timestamp: (new Date()).toISOString(),
rawQuery: queryToProcess || "",
}
setMessages(prevMessages => [...prevMessages, newStreamMessage]);
setProcessQuerySignal(true);
} else {
if (!chatWS) {
console.error("No WebSocket connection available");
}
if (!queryToProcess) {
console.error("No query to process");
}
}
}, [queryToProcess]);
useEffect(() => {
if (processQuerySignal && chatWS) {
setProcessQuerySignal(false);
chatWS.onmessage = handleWebSocketMessage;
chatWS?.send(queryToProcess);
}
}, [processQuerySignal]);
useEffect(() => {
if (chatWS) {
chatWS.onmessage = handleWebSocketMessage;
}
}, [chatWS]);
useEffect(() => {
(async () => {
if (conversationId) {
const newWS = await setupWebSocket(conversationId, queryToProcess);
if (!newWS) {
console.error("No WebSocket connection available");
return;
}
setChatWS(newWS);
// Add a new object to the state
const newStreamMessage: StreamMessage = {
rawResponse: "",
trainOfThought: [],
context: [],
onlineContext: {},
completed: false,
timestamp: (new Date()).toISOString(),
rawQuery: queryToProcess || "",
}
setMessages(prevMessages => [...prevMessages, newStreamMessage]);
}
})();
}, [conversationId]);
if (isLoading) {
return <Loading />;
}
return (
<div className={`${styles.main} ${authenticatedData ? styles.chatLayout : ''}`}>
<title>
{title}
</title>
<div className={styles.sidePanel}>
<SidePanel
webSocketConnected={!!conversationId ? (chatWS != null) : true}
conversationId={conversationId ?? null}
uploadedFiles={uploadedFiles} />
</div>
<div className={styles.chatBox}>
<NavMenu selected="Chat" title={title} />
<div className={styles.chatBoxBody}>
<Suspense fallback={<Loading />}>
<ChatBodyData
conversationId={conversationId}
streamedMessages={messages}
setQueryToProcess={setQueryToProcess}
isLoggedIn={authenticatedData !== null}
publicConversationSlug={params.slug}
chatOptionsData={chatOptionsData}
setTitle={setTitle}
setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth} />
</Suspense>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,127 @@
div.main {
height: 100vh;
color: hsla(var(--foreground));
}
.suggestions {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
justify-content: center;
}
div.inputBox {
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 4px 10px var(--box-shadow-color);
margin-bottom: 20px;
gap: 12px;
padding-left: 20px;
padding-right: 20px;
align-content: center;
}
input.inputBox {
border: none;
}
input.inputBox:focus {
outline: none;
background-color: transparent;
}
div.inputBox:focus {
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
}
div.chatBodyFull {
display: grid;
grid-template-columns: 1fr;
height: 100%;
}
button.inputBox {
border: none;
outline: none;
background-color: transparent;
cursor: pointer;
border-radius: 0.5rem;
padding: 0.5rem;
background: linear-gradient(var(--calm-green), var(--calm-blue));
}
div.chatBody {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
}
.inputBox {
color: hsla(var(--foreground));
}
div.chatLayout {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
}
div.chatBox {
display: grid;
height: 100%;
}
div.titleBar {
display: grid;
grid-template-columns: 1fr auto;
}
div.chatBoxBody {
display: grid;
height: 100%;
width: 70%;
margin: auto;
}
div.agentIndicator a {
display: flex;
text-align: center;
align-content: center;
align-items: center;
}
div.agentIndicator {
padding: 10px;
}
@media (max-width: 768px) {
div.chatBody {
grid-template-columns: 0fr 1fr;
}
div.chatBox {
padding: 0;
}
}
@media screen and (max-width: 768px) {
div.inputBox {
margin-bottom: 0px;
}
div.chatBoxBody {
width: 100%;
}
div.chatBox {
padding: 0;
}
div.chatLayout {
gap: 0;
grid-template-columns: 1fr;
}
}

View file

@ -1,41 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"out/types/**/*.ts"
],
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"out/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"exclude": [
"node_modules"
]
}

View file

@ -803,11 +803,14 @@ class ConversationAdapters:
def create_conversation_from_public_conversation(
user: KhojUser, public_conversation: PublicConversation, client_app: ClientApplication
):
scrubbed_title = public_conversation.title if public_conversation.title else public_conversation.slug
if scrubbed_title:
scrubbed_title = scrubbed_title.replace("-", " ")
return Conversation.objects.create(
user=user,
conversation_log=public_conversation.conversation_log,
client=client_app,
slug=public_conversation.slug,
slug=scrubbed_title,
title=public_conversation.title,
agent=public_conversation.agent,
)

View file

@ -269,10 +269,15 @@ def get_shared_chat(
}
meta_log = conversation.conversation_log
scrubbed_title = conversation.title if conversation.title else conversation.slug
if scrubbed_title:
scrubbed_title = scrubbed_title.replace("-", " ")
meta_log.update(
{
"conversation_id": conversation.id,
"slug": conversation.title if conversation.title else conversation.slug,
"slug": scrubbed_title,
"agent": agent_metadata,
}
)
@ -288,7 +293,7 @@ def get_shared_chat(
update_telemetry_state(
request=request,
telemetry_type="api",
api="public_conversation_history",
api="chat_history",
**common.__dict__,
)
@ -330,7 +335,7 @@ def fork_public_conversation(
public_conversation = PublicConversationAdapters.get_public_conversation_by_slug(public_conversation_slug)
# Duplicate Public Conversation to User's Private Conversation
ConversationAdapters.create_conversation_from_public_conversation(
new_conversation = ConversationAdapters.create_conversation_from_public_conversation(
user, public_conversation, request.user.client_app
)
@ -346,7 +351,16 @@ def fork_public_conversation(
redirect_uri = str(request.app.url_path_for("chat_page"))
return Response(status_code=200, content=json.dumps({"status": "ok", "next_url": redirect_uri}))
return Response(
status_code=200,
content=json.dumps(
{
"status": "ok",
"next_url": redirect_uri,
"conversation_id": new_conversation.id,
}
),
)
@api_chat.post("/share")
@ -451,7 +465,6 @@ async def create_chat_session(
@api_chat.get("/options", response_class=Response)
@requires(["authenticated"])
async def chat_options(
request: Request,
common: CommonQueryParams,