mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-03 13:19:16 +00:00
Make most major changes for an updated chat UI (#843)
- Updated references panel - Use subtle coloring for chat cards - Chat streaming with train of thought - Side panel with limited sessions, expandable - Manage conversation file filters easily from the side panel - Updated nav menu, easily go to agents/automations/profile - Upload data from the chat UI (on click attachment icon) - Slash command pop-up menu, scrollable and selectable - Dark mode-enabled - Mostly mobile friendly
This commit is contained in:
@@ -7,6 +7,24 @@ div.chatHistory {
|
||||
div.chatLayout {
|
||||
height: 80vh;
|
||||
overflow-y: auto;
|
||||
/* width: 80%; */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
div.agentIndicator a {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.agentIndicator {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
div.trainOfThought {
|
||||
border: 1px var(--border-color) solid;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin: 12px;
|
||||
box-shadow: 0 4px 10px var(--box-shadow-color);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
import styles from './chatHistory.module.css';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
import ChatMessage, { ChatHistoryData, SingleChatMessage } from '../chatMessage/chatMessage';
|
||||
import ChatMessage, { ChatHistoryData, StreamMessage, TrainOfThought } from '../chatMessage/chatMessage';
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
import renderMathInElement from 'katex/contrib/auto-render';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
import Loading, { InlineLoading } from '../loading/loading';
|
||||
|
||||
import { Lightbulb } from "@phosphor-icons/react";
|
||||
|
||||
interface ChatResponse {
|
||||
status: string;
|
||||
@@ -20,42 +25,117 @@ interface ChatHistory {
|
||||
|
||||
interface ChatHistoryProps {
|
||||
conversationId: string;
|
||||
setReferencePanelData: Function;
|
||||
setShowReferencePanel: Function;
|
||||
setTitle: (title: string) => void;
|
||||
incomingMessages?: StreamMessage[];
|
||||
pendingMessage?: string;
|
||||
publicConversationSlug?: string;
|
||||
}
|
||||
|
||||
|
||||
function constructTrainOfThought(trainOfThought: string[], lastMessage: boolean, key: string, completed: boolean = false) {
|
||||
const lastIndex = trainOfThought.length - 1;
|
||||
return (
|
||||
<div className={`${styles.trainOfThought}`} key={key}>
|
||||
{
|
||||
!completed &&
|
||||
<InlineLoading className='float-right' />
|
||||
}
|
||||
|
||||
{trainOfThought.map((train, index) => (
|
||||
<TrainOfThought key={`train-${index}`} message={train} primary={index === lastIndex && lastMessage && !completed} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chatHistoryRef = useRef(null);
|
||||
const chatHistoryRef = useRef<HTMLDivElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState<number | null>(null);
|
||||
const [fetchingData, setFetchingData] = useState(false);
|
||||
const [isMobileWidth, setIsMobileWidth] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
setIsMobileWidth(window.innerWidth < 768);
|
||||
});
|
||||
|
||||
fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=10`)
|
||||
.then(response => response.json())
|
||||
.then((chatData: ChatResponse) => {
|
||||
setLoading(false);
|
||||
// Render chat options, if any
|
||||
if (chatData) {
|
||||
console.log(chatData);
|
||||
setData(chatData.response);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}, [props.conversationId]);
|
||||
setIsMobileWidth(window.innerWidth < 768);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// This function ensures that scrolling to bottom happens after the data (chat messages) has been updated and rendered the first time.
|
||||
const scrollToBottomAfterDataLoad = () => {
|
||||
// Assume the data is loading in this scenario.
|
||||
if (!data?.chat.length) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentPage < 2) {
|
||||
// Call the function defined above.
|
||||
scrollToBottomAfterDataLoad();
|
||||
}
|
||||
|
||||
}, [chatHistoryRef.current, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMoreMessages || fetchingData) return;
|
||||
|
||||
// TODO: A future optimization would be to add a time to delay to re-enabling the intersection observer.
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMoreMessages) {
|
||||
setFetchingData(true);
|
||||
fetchMoreMessages(currentPage);
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
}, { threshold: 1.0 });
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observer.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sentinelRef.current, hasMoreMessages, currentPage, fetchingData]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMoreMessages(true);
|
||||
setFetchingData(false);
|
||||
setCurrentPage(0);
|
||||
setData(null);
|
||||
}, [props.conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(props.incomingMessages);
|
||||
if (props.incomingMessages) {
|
||||
const lastMessage = props.incomingMessages[props.incomingMessages.length - 1];
|
||||
if (lastMessage && !lastMessage.completed) {
|
||||
setIncompleteIncomingMessageIndex(props.incomingMessages.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserAtBottom()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
}, [props.incomingMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
// If the addedNodes property has one or more nodes
|
||||
for(let mutation of mutationsList) {
|
||||
if(mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
for (let mutation of mutationsList) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
// Call your function here
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
@@ -77,24 +157,175 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <h2>🌀 Loading...</h2>;
|
||||
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`;
|
||||
return `/agents?agent=${data.agent.slug}`
|
||||
}
|
||||
|
||||
function constructAgentAvatar() {
|
||||
if (!data || !data.agent || !data.agent.avatar) return `/avatar.png`;
|
||||
return data.agent.avatar;
|
||||
}
|
||||
|
||||
function constructAgentName() {
|
||||
if (!data || !data.agent || !data.agent.name) return `Agent`;
|
||||
return data.agent.name;
|
||||
}
|
||||
|
||||
|
||||
if (!props.conversationId && !props.publicConversationSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.main + " " + styles.chatLayout}>
|
||||
<ScrollArea className={`h-[80vh]`}>
|
||||
<div ref={ref}>
|
||||
<div className={styles.chatHistory} ref={chatHistoryRef}>
|
||||
<div ref={sentinelRef} style={{ height: '1px' }}>
|
||||
{fetchingData && <InlineLoading />}
|
||||
</div>
|
||||
{(data && data.chat) && data.chat.map((chatMessage, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
key={`${index}fullHistory`}
|
||||
isMobileWidth={isMobileWidth}
|
||||
chatMessage={chatMessage}
|
||||
setReferencePanelData={props.setReferencePanelData}
|
||||
setShowReferencePanel={props.setShowReferencePanel}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
))}
|
||||
{
|
||||
props.incomingMessages && props.incomingMessages.map((message, index) => {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
key={`${index}outgoing`}
|
||||
isMobileWidth={isMobileWidth}
|
||||
chatMessage={
|
||||
{
|
||||
message: message.rawQuery,
|
||||
context: [],
|
||||
onlineContext: {},
|
||||
created: message.timestamp,
|
||||
by: "you",
|
||||
automationId: '',
|
||||
}
|
||||
}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500' />
|
||||
{
|
||||
message.trainOfThought &&
|
||||
constructTrainOfThought(
|
||||
message.trainOfThought,
|
||||
index === incompleteIncomingMessageIndex,
|
||||
`${index}trainOfThought`, message.completed)
|
||||
}
|
||||
<ChatMessage
|
||||
key={`${index}incoming`}
|
||||
isMobileWidth={isMobileWidth}
|
||||
chatMessage={
|
||||
{
|
||||
message: message.rawResponse,
|
||||
context: message.context,
|
||||
onlineContext: message.onlineContext,
|
||||
created: message.timestamp,
|
||||
by: "khoj",
|
||||
automationId: '',
|
||||
rawQuery: message.rawQuery,
|
||||
}
|
||||
}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
props.pendingMessage &&
|
||||
<ChatMessage
|
||||
key={`pendingMessage-${props.pendingMessage.length}`}
|
||||
isMobileWidth={isMobileWidth}
|
||||
chatMessage={
|
||||
{
|
||||
message: props.pendingMessage,
|
||||
context: [],
|
||||
onlineContext: {},
|
||||
created: (new Date().getTime()).toString(),
|
||||
by: "you",
|
||||
automationId: '',
|
||||
}
|
||||
}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
}
|
||||
<div className={`${styles.agentIndicator}`}>
|
||||
<a className='no-underline mx-2 flex text-muted-foreground' href={constructAgentLink()} target="_blank" rel="noreferrer">
|
||||
<Lightbulb color='orange' weight='fill' />
|
||||
<span>{constructAgentName()}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
div.actualInputArea {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
}
|
||||
318
src/interface/web/app/components/chatInputArea/chatInputArea.tsx
Normal file
318
src/interface/web/app/components/chatInputArea/chatInputArea.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
|
||||
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,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Popover, PopoverContent } from '@/components/ui/popover';
|
||||
import { PopoverTrigger } from '@radix-ui/react-popover';
|
||||
import Link from 'next/link';
|
||||
import { AlertDialogCancel } from '@radix-ui/react-alert-dialog';
|
||||
import LoginPrompt from '../loginPrompt/loginPrompt';
|
||||
|
||||
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 [loginRedirectMessage, setLoginRedirectMessage] = useState<string | null>(null);
|
||||
const [showLoginPrompt, setShowLoginPrompt] = 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() {
|
||||
if (!message.trim()) return;
|
||||
|
||||
if (!props.isLoggedIn) {
|
||||
setLoginRedirectMessage('Hey there, you need to be signed in to send messages to Khoj AI');
|
||||
setShowLoginPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
props.sendMessage(message.trim());
|
||||
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;
|
||||
|
||||
if (!props.isLoggedIn) {
|
||||
setLoginRedirectMessage('Whoa! You need to login to upload files');
|
||||
setShowLoginPrompt(true);
|
||||
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 (
|
||||
<>
|
||||
{
|
||||
showLoginPrompt && loginRedirectMessage && (
|
||||
<LoginPrompt
|
||||
onOpenChange={setShowLoginPrompt}
|
||||
loginRedirectMessage={loginRedirectMessage} />
|
||||
)
|
||||
}
|
||||
{
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,46 @@
|
||||
div.chatMessageContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 12px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 10px var(--box-shadow-color)
|
||||
}
|
||||
|
||||
div.chatMessageWrapper {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
div.khojfullHistory {
|
||||
border-width: 1px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
div.youfullHistory {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
div.chatMessageContainer.youfullHistory {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
div.you {
|
||||
color: var(--frosted-background-color);
|
||||
background-color: var(--intense-green);
|
||||
background-color: hsla(var(--secondary));
|
||||
align-self: flex-end;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
div.khoj {
|
||||
background-color: transparent;
|
||||
color: #000000;
|
||||
color: hsl(var(--accent-foreground));
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
div.khojChatMessage {
|
||||
padding-top: 8px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
div.chatMessageContainer img {
|
||||
width: 50%;
|
||||
}
|
||||
@@ -23,8 +49,8 @@ div.chatMessageContainer h3 img {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
div.you .author {
|
||||
color: var(--frosted-background-color);
|
||||
div.you {
|
||||
color: hsla(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
div.author {
|
||||
@@ -36,31 +62,32 @@ div.author {
|
||||
div.chatFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
div.chatButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border: var(--border-color) 1px solid;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
bottom: -28px;
|
||||
background-color: hsla(var(--secondary));
|
||||
box-shadow: 0 4px 10px var(--box-shadow-color);
|
||||
}
|
||||
|
||||
div.chatFooter button {
|
||||
cursor: pointer;
|
||||
background-color: var(--calm-blue);
|
||||
color: var(--main-text-color);
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
border-radius: 16px;
|
||||
padding: 4px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
div.chatFooter button:hover {
|
||||
background-color: var(--frosted-background-color);
|
||||
color: var(--intense-green);
|
||||
}
|
||||
|
||||
div.chatTimestamp {
|
||||
font-size: small;
|
||||
background-color: hsla(var(--frosted-background-color));
|
||||
}
|
||||
|
||||
button.codeCopyButton {
|
||||
@@ -70,11 +97,35 @@ button.codeCopyButton {
|
||||
}
|
||||
|
||||
button.codeCopyButton:hover {
|
||||
background-color: var(--intense-green);
|
||||
color: var(--frosted-background-color);
|
||||
color: hsla(var(--frosted-background-color));
|
||||
}
|
||||
|
||||
div.feedbackButtons img,
|
||||
button.codeCopyButton img,
|
||||
button.copyButton img {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
div.trainOfThought strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
div.trainOfThought.primary strong {
|
||||
font-weight: 500;
|
||||
color: hsla(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
div.trainOfThought.primary p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
div.youfullHistory {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.chatMessageWrapper {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import styles from './chatMessage.module.css';
|
||||
import markdownIt from 'markdown-it';
|
||||
import mditHljs from "markdown-it-highlightjs";
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
import { hasValidReferences } from '../referencePanel/referencePanel';
|
||||
import { TeaserReferencesSection, constructAllReferences } from '../referencePanel/referencePanel';
|
||||
|
||||
import { ThumbsUp, ThumbsDown, Copy, Brain, Cloud, Folder, Book, Aperture, ArrowRight, SpeakerHifi } from '@phosphor-icons/react';
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr';
|
||||
|
||||
import * as DomPurify from 'dompurify';
|
||||
|
||||
const md = new markdownIt({
|
||||
html: true,
|
||||
@@ -77,23 +80,37 @@ interface AgentData {
|
||||
|
||||
interface Intent {
|
||||
type: string;
|
||||
query: string;
|
||||
"memory-type": string;
|
||||
"inferred-queries": string[];
|
||||
}
|
||||
|
||||
export interface SingleChatMessage {
|
||||
automationId: string;
|
||||
by: string;
|
||||
intent: {
|
||||
[key: string]: string
|
||||
}
|
||||
message: string;
|
||||
context: Context[];
|
||||
created: string;
|
||||
onlineContext: {
|
||||
[key: string]: OnlineContextData
|
||||
}
|
||||
rawQuery?: string;
|
||||
intent?: Intent;
|
||||
}
|
||||
|
||||
export interface StreamMessage {
|
||||
rawResponse: string;
|
||||
trainOfThought: string[];
|
||||
context: Context[];
|
||||
onlineContext: {
|
||||
[key: string]: OnlineContextData
|
||||
}
|
||||
completed: boolean;
|
||||
rawQuery: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
export interface ChatHistoryData {
|
||||
chat: SingleChatMessage[];
|
||||
agent: AgentData;
|
||||
@@ -101,71 +118,130 @@ export interface ChatHistoryData {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function FeedbackButtons() {
|
||||
function sendFeedback(uquery: string, kquery: string, sentiment: string) {
|
||||
fetch('/api/chat/feedback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ uquery: uquery, kquery: kquery, sentiment: sentiment })
|
||||
})
|
||||
}
|
||||
|
||||
function FeedbackButtons({ uquery, kquery }: { uquery: string, kquery: string }) {
|
||||
return (
|
||||
<div className={styles.feedbackButtons}>
|
||||
<button className={styles.thumbsUpButton}>
|
||||
<Image
|
||||
src="/thumbs-up.svg"
|
||||
alt="Thumbs Up"
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
<div className={`${styles.feedbackButtons} flex align-middle justify-center items-center`}>
|
||||
<button className={styles.thumbsUpButton} onClick={() => sendFeedback(uquery, kquery, 'positive')}>
|
||||
<ThumbsUp color='hsl(var(--muted-foreground))' />
|
||||
</button>
|
||||
<button className={styles.thumbsDownButton}>
|
||||
<Image
|
||||
src="/thumbs-down.svg"
|
||||
alt="Thumbs Down"
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
<button className={styles.thumbsDownButton} onClick={() => sendFeedback(uquery, kquery, 'negative')}>
|
||||
<ThumbsDown color='hsl(var(--muted-foreground))' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function onClickMessage(event: React.MouseEvent<any>, chatMessage: SingleChatMessage, setReferencePanelData: Function, setShowReferencePanel: Function) {
|
||||
event.preventDefault();
|
||||
setReferencePanelData(chatMessage);
|
||||
setShowReferencePanel(true);
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
chatMessage: SingleChatMessage;
|
||||
setReferencePanelData: Function;
|
||||
setShowReferencePanel: Function;
|
||||
isMobileWidth: boolean;
|
||||
customClassName?: string;
|
||||
borderLeftColor?: string;
|
||||
}
|
||||
|
||||
interface TrainOfThoughtProps {
|
||||
message: string;
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
function chooseIconFromHeader(header: string, iconColor: string) {
|
||||
const compareHeader = header.toLowerCase();
|
||||
if (compareHeader.includes("understanding")) {
|
||||
return <Brain className={`inline mr-2 ${iconColor}`} />
|
||||
}
|
||||
|
||||
if (compareHeader.includes("generating")) {
|
||||
return <Cloud className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("data sources")) {
|
||||
return <Folder className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("notes")) {
|
||||
return <Folder className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("read")) {
|
||||
return <Book className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("search")) {
|
||||
return <MagnifyingGlass className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("summary") || compareHeader.includes("summarize")) {
|
||||
return <Aperture className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
return <Brain className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
|
||||
export function TrainOfThought(props: TrainOfThoughtProps) {
|
||||
// The train of thought comes in as a markdown-formatted string. It starts with a heading delimited by two asterisks at the start and end and a colon, followed by the message. Example: **header**: status. This function will parse the message and render it as a div.
|
||||
let extractedHeader = props.message.match(/\*\*(.*)\*\*/);
|
||||
let header = extractedHeader ? extractedHeader[1] : "";
|
||||
const iconColor = props.primary ? 'text-orange-400' : 'text-gray-500';
|
||||
const icon = chooseIconFromHeader(header, iconColor);
|
||||
let markdownRendered = DomPurify.sanitize(md.render(props.message));
|
||||
return (
|
||||
<div className={`flex items-center ${props.primary ? 'text-gray-400' : 'text-gray-300'} ${styles.trainOfThought} ${props.primary ? styles.primary : ''}`} >
|
||||
{icon}
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownRendered }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatMessage(props: ChatMessageProps) {
|
||||
const [copySuccess, setCopySuccess] = useState<boolean>(false);
|
||||
|
||||
let message = props.chatMessage.message;
|
||||
|
||||
// Replace LaTeX delimiters with placeholders
|
||||
message = message.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN')
|
||||
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
|
||||
|
||||
if (props.chatMessage.intent && props.chatMessage.intent.type == "text-to-image2") {
|
||||
message = `\n\n${props.chatMessage.intent["inferred-queries"][0]}`
|
||||
}
|
||||
|
||||
let markdownRendered = md.render(message);
|
||||
|
||||
// Replace placeholders with LaTeX delimiters
|
||||
markdownRendered = markdownRendered.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)')
|
||||
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
|
||||
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const [markdownRendered, setMarkdownRendered] = useState<string>('');
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let message = props.chatMessage.message;
|
||||
|
||||
// Replace LaTeX delimiters with placeholders
|
||||
message = message.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN')
|
||||
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
|
||||
|
||||
if (props.chatMessage.intent && props.chatMessage.intent.type == "text-to-image2") {
|
||||
message = `\n\n${props.chatMessage.intent["inferred-queries"][0]}`
|
||||
}
|
||||
|
||||
let markdownRendered = md.render(message);
|
||||
|
||||
// Replace placeholders with LaTeX delimiters
|
||||
markdownRendered = markdownRendered.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)')
|
||||
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
|
||||
setMarkdownRendered(DomPurify.sanitize(markdownRendered));
|
||||
}, [props.chatMessage.message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copySuccess) {
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
}
|
||||
}, [copySuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageRef.current) {
|
||||
const preElements = messageRef.current.querySelectorAll('pre > .hljs');
|
||||
preElements.forEach((preElement) => {
|
||||
const copyButton = document.createElement('button');
|
||||
const copyImage = document.createElement('img');
|
||||
copyImage.src = '/copy-button.svg';
|
||||
copyImage.src = '/static/copy-button.svg';
|
||||
copyImage.alt = 'Copy';
|
||||
copyImage.width = 24;
|
||||
copyImage.height = 24;
|
||||
@@ -179,80 +255,126 @@ export default function ChatMessage(props: ChatMessageProps) {
|
||||
textContent = textContent.replace(/^Copy/, '');
|
||||
textContent = textContent.trim();
|
||||
navigator.clipboard.writeText(textContent);
|
||||
copyImage.src = '/static/copy-button-success.svg';
|
||||
});
|
||||
preElement.prepend(copyButton);
|
||||
});
|
||||
}
|
||||
}, [markdownRendered]);
|
||||
}, [markdownRendered, isHovering, messageRef]);
|
||||
|
||||
function renderTimeStamp(timestamp: string) {
|
||||
var dateObject = new Date(timestamp);
|
||||
var month = dateObject.getMonth() + 1;
|
||||
var date = dateObject.getDate();
|
||||
var year = dateObject.getFullYear();
|
||||
const formattedDate = `${month}/${date}/${year}`;
|
||||
return `${formattedDate} ${dateObject.toLocaleTimeString()}`;
|
||||
if (!props.chatMessage.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (copySuccess) {
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
function renderTimeStamp(timestamp: string) {
|
||||
if (!timestamp.endsWith('Z')) {
|
||||
timestamp = timestamp + 'Z';
|
||||
}
|
||||
}, [copySuccess]);
|
||||
const messageDateTime = new Date(timestamp);
|
||||
const currentDataTime = new Date();
|
||||
const timeDiff = currentDataTime.getTime() - messageDateTime.getTime();
|
||||
|
||||
let referencesValid = hasValidReferences(props.chatMessage);
|
||||
if (timeDiff < 60000) {
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
if (timeDiff < 3600000) {
|
||||
// Using Math.round for closer to actual time representation
|
||||
return `${Math.round(timeDiff / 60000)}m ago`;
|
||||
}
|
||||
|
||||
if (timeDiff < 86400000) {
|
||||
return `${Math.round(timeDiff / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
return `${Math.round(timeDiff / 86400000)}d ago`;
|
||||
}
|
||||
|
||||
function constructClasses(chatMessage: SingleChatMessage) {
|
||||
let classes = [styles.chatMessageContainer];
|
||||
classes.push(styles[chatMessage.by]);
|
||||
|
||||
if (props.customClassName) {
|
||||
classes.push(styles[`${chatMessage.by}${props.customClassName}`])
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
function chatMessageWrapperClasses(chatMessage: SingleChatMessage) {
|
||||
let classes = [styles.chatMessageWrapper];
|
||||
classes.push(styles[chatMessage.by]);
|
||||
|
||||
if (chatMessage.by === "khoj") {
|
||||
const dynamicBorderColor = `border-l-${props.borderLeftColor}`;
|
||||
classes.push(`border-l-4 border-opacity-50 border-l-orange-400 ${dynamicBorderColor}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
const allReferences = constructAllReferences(props.chatMessage.context, props.chatMessage.onlineContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.chatMessageContainer} ${styles[props.chatMessage.by]}`}
|
||||
onClick={props.chatMessage.by === "khoj" ? (event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel) : undefined}>
|
||||
{/* <div className={styles.chatFooter}> */}
|
||||
{/* {props.chatMessage.by} */}
|
||||
{/* </div> */}
|
||||
className={constructClasses(props.chatMessage)}
|
||||
onMouseLeave={(event) => setIsHovering(false)}
|
||||
onMouseEnter={(event) => setIsHovering(true)}
|
||||
onClick={props.chatMessage.by === "khoj" ? (event) => undefined : undefined}>
|
||||
<div className={chatMessageWrapperClasses(props.chatMessage)}>
|
||||
<div ref={messageRef} className={styles.chatMessage} dangerouslySetInnerHTML={{ __html: markdownRendered }} />
|
||||
{/* Add a copy button, thumbs up, and thumbs down buttons */}
|
||||
<div className={styles.chatFooter}>
|
||||
<div className={styles.chatTimestamp}>
|
||||
{renderTimeStamp(props.chatMessage.created)}
|
||||
</div>
|
||||
<div className={styles.chatButtons}>
|
||||
{
|
||||
referencesValid &&
|
||||
<div className={styles.referenceButton}>
|
||||
<button onClick={(event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel)}>
|
||||
References
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.teaserReferencesContainer}>
|
||||
<TeaserReferencesSection
|
||||
isMobileWidth={props.isMobileWidth}
|
||||
notesReferenceCardData={allReferences.notesReferenceCardData}
|
||||
onlineReferenceCardData={allReferences.onlineReferenceCardData} />
|
||||
</div>
|
||||
<div className={styles.chatFooter}>
|
||||
{
|
||||
isHovering &&
|
||||
(
|
||||
<>
|
||||
<div className={`text-gray-400 relative top-2 left-2`}>
|
||||
{renderTimeStamp(props.chatMessage.created)}
|
||||
</div>
|
||||
}
|
||||
<button className={`${styles.copyButton}`} onClick={() => {
|
||||
navigator.clipboard.writeText(props.chatMessage.message);
|
||||
setCopySuccess(true);
|
||||
}}>
|
||||
{
|
||||
copySuccess ?
|
||||
<Image
|
||||
src="/copy-button-success.svg"
|
||||
alt="Checkmark"
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
: <Image
|
||||
src="/copy-button.svg"
|
||||
alt="Copy"
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
{
|
||||
props.chatMessage.by === "khoj" && <FeedbackButtons />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chatButtons}>
|
||||
{
|
||||
(props.chatMessage.by === "khoj") &&
|
||||
(
|
||||
<button onClick={(event) => console.log("speaker")}>
|
||||
<SpeakerHifi color='hsl(var(--muted-foreground))' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<button className={`${styles.copyButton}`} onClick={() => {
|
||||
navigator.clipboard.writeText(props.chatMessage.message);
|
||||
setCopySuccess(true);
|
||||
}}>
|
||||
{
|
||||
copySuccess ?
|
||||
<Copy color='green' />
|
||||
: <Copy color='hsl(var(--muted-foreground))' />
|
||||
}
|
||||
</button>
|
||||
{
|
||||
(props.chatMessage.by === "khoj") &&
|
||||
(
|
||||
props.chatMessage.intent ?
|
||||
<FeedbackButtons
|
||||
uquery={props.chatMessage.intent.query}
|
||||
kquery={props.chatMessage.message} />
|
||||
: <FeedbackButtons
|
||||
uquery={props.chatMessage.rawQuery || props.chatMessage.message}
|
||||
kquery={props.chatMessage.message} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
22
src/interface/web/app/components/loading/loading.tsx
Normal file
22
src/interface/web/app/components/loading/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CircleNotch } from '@phosphor-icons/react';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
// NOTE: We can display usage tips here for casual learning moments.
|
||||
<div className={`bg-background opacity-50 flex items-center justify-center h-screen`}>
|
||||
<div>Loading <span><CircleNotch className={`inline animate-spin h-5 w-5`} /></span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineLoadingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineLoading(props: InlineLoadingProps) {
|
||||
return (
|
||||
<button className={`${props.className}`}>
|
||||
<CircleNotch className={`animate-spin h-5 w-5 mr-3`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
42
src/interface/web/app/components/loginPrompt/loginPrompt.tsx
Normal file
42
src/interface/web/app/components/loginPrompt/loginPrompt.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface LoginPromptProps {
|
||||
loginRedirectMessage: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function LoginPrompt(props: LoginPromptProps) {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={true}
|
||||
onOpenChange={props.onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sign in to Khoj to continue</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>{props.loginRedirectMessage}. By logging in, you agree to our <Link href="https://khoj.dev/terms-of-service">Terms of Service.</Link></AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Dismiss</AlertDialogCancel>
|
||||
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500'
|
||||
onClick={() => {
|
||||
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
|
||||
}}>
|
||||
<Link href={`/login?next=${encodeURIComponent(window.location.pathname)}`}> {/* Redirect to login page */}
|
||||
Login
|
||||
</Link>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -11,26 +11,19 @@ menu.menu a {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
menu.menu a.selected {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
menu.menu a:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
menu.menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
a.selected {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
div.titleBar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: 16px 0;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
padding-left: 12px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
justify-content: space-between;
|
||||
align-content: space-evenly;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
div.titleBar menu {
|
||||
@@ -75,14 +68,6 @@ div.settingsMenuOptions {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
div.settingsMenuOptions a {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
div.settingsMenuUsername {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
menu.menu span {
|
||||
display: none;
|
||||
@@ -91,4 +76,8 @@ div.settingsMenuUsername {
|
||||
div.settingsMenuOptions {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
div.titleBar {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import styles from './navMenu.module.css';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useAuthenticatedData, UserProfile } from '@/app/common/auth';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { Moon } from '@phosphor-icons/react';
|
||||
|
||||
|
||||
interface NavMenuProps {
|
||||
selected: string;
|
||||
showLogo?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function SettingsMenu(props: UserProfile) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.settingsMenu}>
|
||||
<div className={styles.settingsMenuProfile} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Image
|
||||
src={props.photo || "/agents.svg"}
|
||||
alt={props.username}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<div className={styles.settingsMenuOptions}>
|
||||
<div className={styles.settingsMenuUsername}>{props.username}</div>
|
||||
<Link href="/config">
|
||||
Settings
|
||||
</Link>
|
||||
<Link href="https://github.com/khoj-ai/khoj">
|
||||
Github
|
||||
</Link>
|
||||
<Link href="https://docs.khoj.dev">
|
||||
Help
|
||||
</Link>
|
||||
<Link href="/auth/logout">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function NavMenu(props: NavMenuProps) {
|
||||
|
||||
let userData = useAuthenticatedData();
|
||||
const userData = useAuthenticatedData();
|
||||
const [displayTitle, setDisplayTitle] = useState<string>(props.title || props.selected.toUpperCase());
|
||||
|
||||
const [isMobileWidth, setIsMobileWidth] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileWidth(window.innerWidth < 768);
|
||||
setDisplayTitle(props.title || props.selected.toUpperCase());
|
||||
|
||||
}, [props.title]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
setIsMobileWidth(window.innerWidth < 768);
|
||||
});
|
||||
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
toggleDarkMode(darkMode);
|
||||
}, [darkMode]);
|
||||
|
||||
function toggleDarkMode(darkMode: boolean) {
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', darkMode ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.titleBar}>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/khoj-logo.svg"
|
||||
alt="Khoj Logo"
|
||||
className={styles.logo}
|
||||
width={100}
|
||||
height={50}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<menu className={styles.menu}>
|
||||
<a className={props.selected === "Chat" ? styles.selected : ""} href = '/chat'>
|
||||
<Image
|
||||
src="/chat.svg"
|
||||
alt="Chat Logo"
|
||||
className={styles.lgoo}
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
<span>
|
||||
Chat
|
||||
</span>
|
||||
</a>
|
||||
<a className={props.selected === "Agents" ? styles.selected : ""} href='/agents'>
|
||||
<Image
|
||||
src="/agents.svg"
|
||||
alt="Agent Logo"
|
||||
className={styles.lgoo}
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
<span>
|
||||
Agents
|
||||
</span>
|
||||
</a>
|
||||
<a className={props.selected === "Automations" ? styles.selected : ""} href = '/automations'>
|
||||
<Image
|
||||
src="/automation.svg"
|
||||
alt="Automation Logo"
|
||||
className={styles.lgoo}
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
<span>
|
||||
Automations
|
||||
</span>
|
||||
</a>
|
||||
{userData && <SettingsMenu {...userData} />}
|
||||
</menu>
|
||||
<div className={`text-nowrap text-ellipsis overflow-hidden max-w-screen-md grid items-top font-bold mr-8`}>
|
||||
{displayTitle && <h2 className={`text-lg text-ellipsis whitespace-nowrap overflow-x-hidden`} >{displayTitle}</h2>}
|
||||
</div>
|
||||
{
|
||||
isMobileWidth ?
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>=</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href='/chat' className={`${props.selected.toLowerCase() === 'chat' ? styles.selected : ''} hover:bg-background`}>Chat</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href='/agents' className={`${props.selected.toLowerCase() === 'agent' ? styles.selected : ''} hover:bg-background`}>Agents</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href='/automations' className={`${props.selected.toLowerCase() === 'automations' ? styles.selected : ''} hover:bg-background`}>Automations</Link>
|
||||
</DropdownMenuItem>
|
||||
{userData && <>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Profile</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/config">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href="https://docs.khoj.dev">Help</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/auth/logout">Logout</Link>
|
||||
</DropdownMenuItem>
|
||||
</>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
:
|
||||
<Menubar className='items-top inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground'>
|
||||
<MenubarMenu>
|
||||
<Link href='/chat' target="_blank" rel="noreferrer" className={`${props.selected.toLowerCase() === 'chat' ? styles.selected : ''} hover:bg-background`}>
|
||||
<MenubarTrigger>Chat</MenubarTrigger>
|
||||
</Link>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<Link href='/agents' target="_blank" rel="noreferrer" className={`${props.selected.toLowerCase() === 'agent' ? styles.selected : ''} hover:bg-background`}>
|
||||
<MenubarTrigger>Agents</MenubarTrigger>
|
||||
</Link>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<Link href='/automations' target="_blank" rel="noreferrer" className={`${props.selected.toLowerCase() === 'automations' ? styles.selected : ''} hover:bg-background`}>
|
||||
<MenubarTrigger>Automations</MenubarTrigger>
|
||||
</Link>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Profile</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<Toggle
|
||||
pressed={darkMode}
|
||||
onClick={() => {
|
||||
setDarkMode(!darkMode)
|
||||
}
|
||||
}>
|
||||
<Moon />
|
||||
</Toggle>
|
||||
</MenubarItem>
|
||||
{userData &&
|
||||
<>
|
||||
<MenubarItem>
|
||||
<Link href="/config">
|
||||
Settings
|
||||
</Link>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
<Link href="https://docs.khoj.dev">
|
||||
Help
|
||||
</Link>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
<Link href="/auth/logout">
|
||||
Logout
|
||||
</Link>
|
||||
</MenubarItem>
|
||||
</>
|
||||
}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
div.panel {
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--calm-blue);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
div.panel a {
|
||||
color: var(--intense-green);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.onlineReference,
|
||||
div.contextReference {
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
div.contextReference:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.singleReference {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--frosted-background-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import styles from "./referencePanel.module.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, File } from "@phosphor-icons/react";
|
||||
|
||||
import markdownIt from "markdown-it";
|
||||
const md = new markdownIt({
|
||||
@@ -11,183 +11,329 @@ const md = new markdownIt({
|
||||
typographer: true
|
||||
});
|
||||
|
||||
import { SingleChatMessage, Context, WebPage, OnlineContextData } from "../chatMessage/chatMessage";
|
||||
import { Context, WebPage, OnlineContextData } from "../chatMessage/chatMessage";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ReferencePanelProps {
|
||||
referencePanelData: SingleChatMessage | null;
|
||||
setShowReferencePanel: (showReferencePanel: boolean) => void;
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import * as DomPurify from 'dompurify';
|
||||
|
||||
interface NotesContextReferenceData {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function hasValidReferences(referencePanelData: SingleChatMessage | null) {
|
||||
interface NotesContextReferenceCardProps extends NotesContextReferenceData {
|
||||
showFullContent: boolean;
|
||||
}
|
||||
|
||||
|
||||
function NotesContextReferenceCard(props: NotesContextReferenceCardProps) {
|
||||
const snippet = props.showFullContent ? DomPurify.sanitize(md.render(props.content)) : DomPurify.sanitize(props.content);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
return (
|
||||
referencePanelData &&
|
||||
(
|
||||
(referencePanelData.context && referencePanelData.context.length > 0) ||
|
||||
(referencePanelData.onlineContext && Object.keys(referencePanelData.onlineContext).length > 0 &&
|
||||
Object.values(referencePanelData.onlineContext).some(
|
||||
(onlineContextData) =>
|
||||
(onlineContextData.webpages && onlineContextData.webpages.length > 0)|| onlineContextData.answerBox || onlineContextData.peopleAlsoAsk || onlineContextData.knowledgeGraph))
|
||||
)
|
||||
);
|
||||
<>
|
||||
<Popover
|
||||
open={isHovering && !props.showFullContent}
|
||||
onOpenChange={setIsHovering}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Card
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={`${props.showFullContent ? 'w-auto' : 'w-[200px]'} overflow-hidden break-words text-balance rounded-lg p-2 bg-muted border-none`}
|
||||
>
|
||||
<h3 className={`${props.showFullContent ? 'block' : 'line-clamp-1'} text-muted-foreground}`}>
|
||||
<File className='w-6 h-6 text-muted-foreground inline-flex' />
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className={`${props.showFullContent ? 'block' : 'overflow-hidden line-clamp-2'}`} dangerouslySetInnerHTML={{ __html: snippet }}></p>
|
||||
</Card>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] mx-2">
|
||||
<Card className={`w-auto overflow-hidden break-words text-balance rounded-lg p-2 border-none`}>
|
||||
<h3 className={`line-clamp-2 text-muted-foreground}`}>
|
||||
<File className='w-6 h-6 text-muted-foreground inline-flex' />
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className={`overflow-hidden line-clamp-3`} dangerouslySetInnerHTML={{ __html: snippet }}></p>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CompiledReference(props: { context: (Context | string) }) {
|
||||
export interface ReferencePanelData {
|
||||
notesReferenceCardData: NotesContextReferenceData[];
|
||||
onlineReferenceCardData: OnlineReferenceData[];
|
||||
}
|
||||
|
||||
let snippet = "";
|
||||
let file = "";
|
||||
if (typeof props.context === "string") {
|
||||
// Treat context as a string and get the first line for the file name
|
||||
const lines = props.context.split("\n");
|
||||
file = lines[0];
|
||||
snippet = lines.slice(1).join("\n");
|
||||
} else {
|
||||
const context = props.context as Context;
|
||||
snippet = context.compiled;
|
||||
file = context.file;
|
||||
|
||||
interface OnlineReferenceData {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface OnlineReferenceCardProps extends OnlineReferenceData {
|
||||
showFullContent: boolean;
|
||||
}
|
||||
|
||||
function GenericOnlineReferenceCard(props: OnlineReferenceCardProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
if (!props.link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showSnippet, setShowSnippet] = useState(false);
|
||||
const domain = new URL(props.link).hostname;
|
||||
const favicon = `https://www.google.com/s2/favicons?domain=${domain}`;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovering(true);
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovering(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.singleReference}>
|
||||
<div className={styles.contextReference} onClick={() => setShowSnippet(!showSnippet)}>
|
||||
<div className={styles.referencePanelTitle}>
|
||||
{file}
|
||||
</div>
|
||||
<div className={styles.referencePanelContent} style={{ display: showSnippet ? "block" : "none" }}>
|
||||
<div>
|
||||
{snippet}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<>
|
||||
<Popover
|
||||
open={isHovering && !props.showFullContent}
|
||||
onOpenChange={setIsHovering}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Card
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`${props.showFullContent ? 'w-auto' : 'w-[200px]'} overflow-hidden break-words rounded-lg text-balance p-2 bg-muted border-none`}>
|
||||
|
||||
function WebPageReference(props: { webpages: WebPage, query: string | null }) {
|
||||
|
||||
let snippet = md.render(props.webpages.snippet);
|
||||
|
||||
const [showSnippet, setShowSnippet] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.onlineReference} onClick={() => setShowSnippet(!showSnippet)}>
|
||||
<div className={styles.onlineReferenceTitle}>
|
||||
<a href={props.webpages.link} target="_blank" rel="noreferrer">
|
||||
{
|
||||
props.query ? (
|
||||
<span>
|
||||
{props.query}
|
||||
</span>
|
||||
) : <span>
|
||||
{props.webpages.query}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.onlineReferenceContent} style={{ display: showSnippet ? "block" : "none" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: snippet }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OnlineReferences(props: { onlineContext: OnlineContextData, query: string}) {
|
||||
|
||||
const webpages = props.onlineContext.webpages;
|
||||
const answerBox = props.onlineContext.answerBox;
|
||||
const peopleAlsoAsk = props.onlineContext.peopleAlsoAsk;
|
||||
const knowledgeGraph = props.onlineContext.knowledgeGraph;
|
||||
|
||||
return (
|
||||
<div className={styles.singleReference}>
|
||||
{
|
||||
webpages && (
|
||||
!Array.isArray(webpages) ? (
|
||||
<WebPageReference webpages={webpages} query={props.query} />
|
||||
) : (
|
||||
webpages.map((webpage, index) => {
|
||||
return <WebPageReference webpages={webpage} key={index} query={null} />
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
answerBox && (
|
||||
<div className={styles.onlineReference}>
|
||||
<div className={styles.onlineReferenceTitle}>
|
||||
{answerBox.title}
|
||||
</div>
|
||||
<div className={styles.onlineReferenceContent}>
|
||||
<div>
|
||||
{answerBox.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
peopleAlsoAsk && peopleAlsoAsk.map((people, index) => {
|
||||
return (
|
||||
<div className={styles.onlineReference} key={index}>
|
||||
<div className={styles.onlineReferenceTitle}>
|
||||
<a href={people.link} target="_blank" rel="noreferrer">
|
||||
{people.question}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.onlineReferenceContent}>
|
||||
<div>
|
||||
{people.snippet}
|
||||
<div className='flex flex-col'>
|
||||
<a href={props.link} target="_blank" rel="noreferrer" className='!no-underline p-2'>
|
||||
<div className='flex items-center'>
|
||||
<img src={favicon} alt="" className='!w-4 h-4 mr-2' />
|
||||
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-1'} text-muted-foreground`}>{domain}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
knowledgeGraph && (
|
||||
<div className={styles.onlineReference}>
|
||||
<div className={styles.onlineReferenceTitle}>
|
||||
<a href={knowledgeGraph.descriptionLink} target="_blank" rel="noreferrer">
|
||||
{knowledgeGraph.title}
|
||||
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-1'} font-bold`}>{props.title}</h3>
|
||||
<p className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'}`}>{props.description}</p>
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.onlineReferenceContent}>
|
||||
<div>
|
||||
{knowledgeGraph.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Card>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] mx-2">
|
||||
<Card
|
||||
className={`w-auto overflow-hidden break-words text-balance rounded-lg border-none`}>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<a href={props.link} target="_blank" rel="noreferrer" className='!no-underline p-2'>
|
||||
<div className='flex items-center'>
|
||||
<img src={favicon} alt="" className='!w-4 h-4 mr-2' />
|
||||
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'} text-muted-foreground`}>{domain}</h3>
|
||||
</div>
|
||||
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'} font-bold`}>{props.title}</h3>
|
||||
<p className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-3'}`}>{props.description}</p>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReferencePanel(props: ReferencePanelProps) {
|
||||
export function constructAllReferences(contextData: Context[], onlineData: { [key: string]: OnlineContextData }) {
|
||||
|
||||
if (!props.referencePanelData) {
|
||||
return null;
|
||||
const onlineReferences: OnlineReferenceData[] = [];
|
||||
const contextReferences: NotesContextReferenceData[] = [];
|
||||
|
||||
if (onlineData) {
|
||||
let localOnlineReferences = [];
|
||||
for (const [key, value] of Object.entries(onlineData)) {
|
||||
if (value.answerBox) {
|
||||
localOnlineReferences.push({
|
||||
title: value.answerBox.title,
|
||||
description: value.answerBox.answer,
|
||||
link: value.answerBox.source
|
||||
});
|
||||
}
|
||||
if (value.knowledgeGraph) {
|
||||
localOnlineReferences.push({
|
||||
title: value.knowledgeGraph.title,
|
||||
description: value.knowledgeGraph.description,
|
||||
link: value.knowledgeGraph.descriptionLink
|
||||
});
|
||||
}
|
||||
|
||||
if (value.webpages) {
|
||||
// If webpages is of type Array, iterate through it and add each webpage to the localOnlineReferences array
|
||||
if (value.webpages instanceof Array) {
|
||||
let webPageResults = value.webpages.map((webPage) => {
|
||||
return {
|
||||
title: webPage.query,
|
||||
description: webPage.snippet,
|
||||
link: webPage.link
|
||||
}
|
||||
});
|
||||
localOnlineReferences.push(...webPageResults);
|
||||
} else {
|
||||
let singleWebpage = value.webpages as WebPage;
|
||||
|
||||
// If webpages is an object, add the object to the localOnlineReferences array
|
||||
localOnlineReferences.push({
|
||||
title: singleWebpage.query,
|
||||
description: singleWebpage.snippet,
|
||||
link: singleWebpage.link
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.organic) {
|
||||
let organicResults = value.organic.map((organicContext) => {
|
||||
return {
|
||||
title: organicContext.title,
|
||||
description: organicContext.snippet,
|
||||
link: organicContext.link
|
||||
}
|
||||
});
|
||||
|
||||
localOnlineReferences.push(...organicResults);
|
||||
}
|
||||
}
|
||||
|
||||
onlineReferences.push(...localOnlineReferences);
|
||||
}
|
||||
|
||||
if (!hasValidReferences(props.referencePanelData)) {
|
||||
return null;
|
||||
if (contextData) {
|
||||
|
||||
let localContextReferences = contextData.map((context) => {
|
||||
if (!context.compiled) {
|
||||
const fileContent = context as unknown as string;
|
||||
const title = fileContent.split('\n')[0];
|
||||
const content = fileContent.split('\n').slice(1).join('\n');
|
||||
return {
|
||||
title: title,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: context.file,
|
||||
content: context.compiled
|
||||
}
|
||||
});
|
||||
|
||||
contextReferences.push(...localContextReferences);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.panel}`}>
|
||||
References <button onClick={() => props.setShowReferencePanel(false)}>Hide</button>
|
||||
{
|
||||
props.referencePanelData?.context.map((context, index) => {
|
||||
return <CompiledReference context={context} key={index} />
|
||||
})
|
||||
}
|
||||
{
|
||||
Object.entries(props.referencePanelData?.onlineContext || {}).map(([key, onlineContextData], index) => {
|
||||
return <OnlineReferences onlineContext={onlineContextData} query={key} key={index} />
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
notesReferenceCardData: contextReferences,
|
||||
onlineReferenceCardData: onlineReferences
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export interface TeaserReferenceSectionProps {
|
||||
notesReferenceCardData: NotesContextReferenceData[];
|
||||
onlineReferenceCardData: OnlineReferenceData[];
|
||||
isMobileWidth: boolean;
|
||||
}
|
||||
|
||||
export function TeaserReferencesSection(props: TeaserReferenceSectionProps) {
|
||||
const [numTeaserSlots, setNumTeaserSlots] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
setNumTeaserSlots(props.isMobileWidth ? 1 : 3);
|
||||
}, [props.isMobileWidth]);
|
||||
|
||||
const notesDataToShow = props.notesReferenceCardData.slice(0, numTeaserSlots);
|
||||
const onlineDataToShow = notesDataToShow.length < numTeaserSlots ? props.onlineReferenceCardData.slice(0, numTeaserSlots - notesDataToShow.length) : [];
|
||||
|
||||
const shouldShowShowMoreButton = props.notesReferenceCardData.length > 0 || props.onlineReferenceCardData.length > 0;
|
||||
|
||||
const numReferences = props.notesReferenceCardData.length + props.onlineReferenceCardData.length;
|
||||
|
||||
if (numReferences === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${props.isMobileWidth ? 'p-0' : 'p-4'}`}>
|
||||
<h3 className="inline-flex items-center">
|
||||
References
|
||||
<p className="text-gray-400 m-2">
|
||||
{numReferences} sources
|
||||
</p>
|
||||
</h3>
|
||||
<div className={`flex ${props.isMobileWidth ? 'w-[90vw]' : 'w-auto'} space-x-4 mt-2`}>
|
||||
{
|
||||
notesDataToShow.map((note, index) => {
|
||||
return <NotesContextReferenceCard showFullContent={false} {...note} key={`${note.title}-${index}`} />
|
||||
})
|
||||
}
|
||||
{
|
||||
onlineDataToShow.map((online, index) => {
|
||||
return <GenericOnlineReferenceCard showFullContent={false} {...online} key={`${online.title}-${index}`} />
|
||||
})
|
||||
}
|
||||
{
|
||||
shouldShowShowMoreButton &&
|
||||
<ReferencePanel
|
||||
notesReferenceCardData={props.notesReferenceCardData}
|
||||
onlineReferenceCardData={props.onlineReferenceCardData} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface ReferencePanelDataProps {
|
||||
notesReferenceCardData: NotesContextReferenceData[];
|
||||
onlineReferenceCardData: OnlineReferenceData[];
|
||||
}
|
||||
|
||||
export default function ReferencePanel(props: ReferencePanelDataProps) {
|
||||
|
||||
if (!props.notesReferenceCardData && !props.onlineReferenceCardData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger
|
||||
className='text-balance w-[200px] overflow-hidden break-words p-0 bg-transparent border-none text-gray-400 align-middle justify-center items-center !m-0 inline-flex'>
|
||||
View references <ArrowRight className='m-1' />
|
||||
</SheetTrigger>
|
||||
<SheetContent className="overflow-y-scroll">
|
||||
<SheetHeader>
|
||||
<SheetTitle>References</SheetTitle>
|
||||
<SheetDescription>View all references for this response</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col w-auto gap-2 mt-2">
|
||||
{
|
||||
props.notesReferenceCardData.map((note, index) => {
|
||||
return <NotesContextReferenceCard showFullContent={true} {...note} key={`${note.title}-${index}`} />
|
||||
})
|
||||
}
|
||||
{
|
||||
props.onlineReferenceCardData.map((online, index) => {
|
||||
return <GenericOnlineReferenceCard showFullContent={true} {...online} key={`${online.title}-${index}`} />
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,150 +2,787 @@
|
||||
|
||||
import styles from "./sidePanel.module.css";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { UserProfile } from "@/app/common/auth";
|
||||
import { UserProfile, useAuthenticatedData } from "@/app/common/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import Image from "next/image";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
import { InlineLoading } from "../loading/loading";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
import { ArrowRight, ArrowLeft, ArrowDown, Spinner, Check, FolderPlus, DotsThreeVertical, House, StackPlus, UserCirclePlus } from "@phosphor-icons/react";
|
||||
|
||||
interface ChatHistory {
|
||||
conversation_id: string;
|
||||
slug: string;
|
||||
agent_name: string;
|
||||
agent_avatar: string;
|
||||
compressed: boolean;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function ChatSession(prop: ChatHistory) {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { Pencil, Trash, Share } from "@phosphor-icons/react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { modifyFileFilterForConversation } from "@/app/common/chatFunctions";
|
||||
import { ScrollAreaScrollbar } from "@radix-ui/react-scroll-area";
|
||||
|
||||
// Define a fetcher function
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
interface GroupedChatHistory {
|
||||
[key: string]: ChatHistory[];
|
||||
}
|
||||
|
||||
function renameConversation(conversationId: string, newTitle: string) {
|
||||
const editUrl = `/api/chat/title?client=web&conversation_id=${conversationId}&title=${newTitle}`;
|
||||
|
||||
fetch(editUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
function shareConversation(conversationId: string, setShareUrl: (url: string) => void) {
|
||||
const shareUrl = `/api/chat/share?client=web&conversation_id=${conversationId}`;
|
||||
|
||||
fetch(shareUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setShareUrl(data.url);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConversation(conversationId: string) {
|
||||
const deleteUrl = `/api/chat/history?client=web&conversation_id=${conversationId}`;
|
||||
|
||||
fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
interface FilesMenuProps {
|
||||
conversationId: string | null;
|
||||
uploadedFiles: string[];
|
||||
isMobileWidth: boolean;
|
||||
}
|
||||
|
||||
function FilesMenu(props: FilesMenuProps) {
|
||||
// Use SWR to fetch files
|
||||
const { data: files, error } = useSWR<string[]>(props.conversationId ? '/api/config/data/computer' : null, fetcher);
|
||||
const { data: selectedFiles, error: selectedFilesError } = useSWR(props.conversationId ? `/api/chat/conversation/file-filters/${props.conversationId}` : null, fetcher);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unfilteredFiles, setUnfilteredFiles] = useState<string[]>([]);
|
||||
const [addedFiles, setAddedFiles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!files) return;
|
||||
|
||||
// First, sort lexically
|
||||
files.sort();
|
||||
let sortedFiles = files;
|
||||
|
||||
if (addedFiles) {
|
||||
console.log("addedFiles in useeffect hook", addedFiles);
|
||||
sortedFiles = addedFiles.concat(sortedFiles.filter((filename: string) => !addedFiles.includes(filename)));
|
||||
}
|
||||
|
||||
setUnfilteredFiles(sortedFiles);
|
||||
|
||||
}, [files, addedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const file of props.uploadedFiles) {
|
||||
setAddedFiles((addedFiles) => [...addedFiles, file]);
|
||||
}
|
||||
}, [props.uploadedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
setAddedFiles(selectedFiles);
|
||||
}
|
||||
|
||||
}, [selectedFiles]);
|
||||
|
||||
const removeAllFiles = () => {
|
||||
modifyFileFilterForConversation(props.conversationId, addedFiles, setAddedFiles, 'remove');
|
||||
}
|
||||
|
||||
const addAllFiles = () => {
|
||||
modifyFileFilterForConversation(props.conversationId, unfilteredFiles, setAddedFiles, 'add');
|
||||
}
|
||||
|
||||
if (!props.conversationId) return (<></>);
|
||||
|
||||
if (error) return <div>Failed to load files</div>;
|
||||
if (selectedFilesError) return <div>Failed to load selected files</div>;
|
||||
if (!files) return <InlineLoading />;
|
||||
if (!selectedFiles) return <InlineLoading />;
|
||||
|
||||
const FilesMenuCommandBox = () => {
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder="Find file" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading="Quick">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
removeAllFiles();
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4 mr-2" />
|
||||
<span>Clear all</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
addAllFiles();
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4 mr-2" />
|
||||
<span>Select all</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Configure files">
|
||||
{unfilteredFiles.map((filename: string) => (
|
||||
addedFiles && addedFiles.includes(filename) ?
|
||||
<CommandItem
|
||||
key={filename}
|
||||
value={filename}
|
||||
className="bg-accent text-accent-foreground mb-1"
|
||||
onSelect={(value) => {
|
||||
modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'remove');
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
<span className="break-all">{filename}</span>
|
||||
</CommandItem>
|
||||
:
|
||||
<CommandItem
|
||||
key={filename}
|
||||
className="mb-1"
|
||||
value={filename}
|
||||
onSelect={(value) => {
|
||||
modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'add');
|
||||
}}
|
||||
>
|
||||
<span className="break-all">{filename}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isMobileWidth) {
|
||||
return (
|
||||
<>
|
||||
<Drawer>
|
||||
<DrawerTrigger className="bg-background border border-muted p-4 rounded-2xl my-8 text-left inline-flex items-center justify-between w-full">
|
||||
Manage Files <ArrowRight className="h-4 w-4 mx-2" />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Files</DrawerTitle>
|
||||
<DrawerDescription>Manage files for this conversation</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className={`${styles.panelWrapper}`}>
|
||||
<FilesMenuCommandBox />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={prop.conversation_id} className={styles.session}>
|
||||
<Link href={`/chat?conversationId=${prop.conversation_id}`}>
|
||||
<p className={styles.session}>{prop.slug || "New Conversation 🌱"}</p>
|
||||
<>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="w-auto bg-background border border-muted p-4 drop-shadow-sm rounded-2xl my-8">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<h4 className="text-sm font-semibold">
|
||||
Manage Context
|
||||
<p>
|
||||
<span className="text-muted-foreground text-xs">Using {addedFiles.length == 0 ? files.length : addedFiles.length} files</span>
|
||||
</p>
|
||||
</h4>
|
||||
<Button variant="ghost" size="sm" className="w-9 p-0">
|
||||
{
|
||||
isOpen ?
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
:
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
|
||||
}
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={`mx-2`}>
|
||||
<FilesMenuCommandBox />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
interface SessionsAndFilesProps {
|
||||
webSocketConnected?: boolean;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
subsetOrganizedData: GroupedChatHistory | null;
|
||||
organizedData: GroupedChatHistory | null;
|
||||
data: ChatHistory[] | null;
|
||||
userProfile: UserProfile | null;
|
||||
conversationId: string | null;
|
||||
uploadedFiles: string[];
|
||||
isMobileWidth: boolean;
|
||||
}
|
||||
|
||||
function SessionsAndFiles(props: SessionsAndFilesProps) {
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="h-[40vh]">
|
||||
<ScrollAreaScrollbar orientation="vertical" className="h-full w-2.5 border-l border-l-transparent p-[1px]" />
|
||||
<div className={styles.sessionsList}>
|
||||
{props.subsetOrganizedData != null && Object.keys(props.subsetOrganizedData).map((timeGrouping) => (
|
||||
<div key={timeGrouping} className={`my-4`}>
|
||||
<div className={`text-muted-foreground text-sm font-bold p-[0.5rem] `}>
|
||||
{timeGrouping}
|
||||
</div>
|
||||
{props.subsetOrganizedData && props.subsetOrganizedData[timeGrouping].map((chatHistory) => (
|
||||
<ChatSession
|
||||
created={chatHistory.created}
|
||||
compressed={true}
|
||||
key={chatHistory.conversation_id}
|
||||
conversation_id={chatHistory.conversation_id}
|
||||
slug={chatHistory.slug}
|
||||
agent_avatar={chatHistory.agent_avatar}
|
||||
agent_name={chatHistory.agent_name} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{
|
||||
(props.data && props.data.length > 5) && (
|
||||
<ChatSessionsModal data={props.organizedData} />
|
||||
)
|
||||
}
|
||||
<FilesMenu conversationId={props.conversationId} uploadedFiles={props.uploadedFiles} isMobileWidth={props.isMobileWidth} />
|
||||
{props.userProfile &&
|
||||
<UserProfileComponent userProfile={props.userProfile} webSocketConnected={props.webSocketConnected} collapsed={false} />
|
||||
}</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatSessionActionMenuProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
function ChatSessionActionMenu(props: ChatSessionActionMenuProps) {
|
||||
const [renamedTitle, setRenamedTitle] = useState('');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState('');
|
||||
const [showShareUrl, setShowShareUrl] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSharing) {
|
||||
shareConversation(props.conversationId, setShareUrl);
|
||||
setShowShareUrl(true);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [isSharing]);
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isRenaming}
|
||||
onOpenChange={(open) => setIsRenaming(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set a new title for the conversation</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will help you identify the conversation easily, and also help you search for it later.
|
||||
</DialogDescription>
|
||||
<Input
|
||||
value={renamedTitle}
|
||||
onChange={(e) => setRenamedTitle(e.target.value)}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
renameConversation(props.conversationId, renamedTitle);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
type="submit">Rename</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSharing || showShareUrl) {
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open={isSharing || showShareUrl}
|
||||
onOpenChange={(open) => {
|
||||
setShowShareUrl(open)
|
||||
setIsSharing(open)
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conversation Share URL</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sharing this chat session will allow anyone with a link to view the conversation.
|
||||
<Input
|
||||
className="w-full bg-accent text-accent-foreground rounded-md p-2 mt-2"
|
||||
value={shareUrl}
|
||||
readOnly={true}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
{
|
||||
!showShareUrl &&
|
||||
<Button
|
||||
onClick={() => {
|
||||
shareConversation(props.conversationId, setShareUrl);
|
||||
setShowShareUrl(true);
|
||||
}}
|
||||
className="bg-orange-500"
|
||||
disabled><Spinner className="mr-2 h-4 w-4 animate-spin" />Sharing</Button>
|
||||
}
|
||||
{
|
||||
showShareUrl &&
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
}}
|
||||
variant={'default'}>Copy</Button>
|
||||
}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (isDeleting) {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={isDeleting}
|
||||
onOpenChange={(open) => setIsDeleting(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Conversation</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this conversation? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
deleteConversation(props.conversationId);
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
className="bg-rose-500 hover:bg-rose-600">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => setIsOpen(open)}
|
||||
open={isOpen}>
|
||||
<DropdownMenuTrigger><DotsThreeVertical className="h-4 w-4" /></DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button className="p-0 text-sm h-auto" variant={'ghost'} onClick={() => setIsRenaming(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />Rename
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Button className="p-0 text-sm h-auto" variant={'ghost'} onClick={() => setIsSharing(true)}>
|
||||
<Share className="mr-2 h-4 w-4" />Share
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Button className="p-0 text-sm h-auto text-rose-300 hover:text-rose-400" variant={'ghost'} onClick={() => setIsDeleting(true)}>
|
||||
<Trash className="mr-2 h-4 w-4" />Delete
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatSession(props: ChatHistory) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
key={props.conversation_id}
|
||||
className={`${styles.session} ${props.compressed ? styles.compressed : '!max-w-full'} ${isHovered ? `${styles.sessionHover}` : ''}`}>
|
||||
<Link href={`/chat?conversationId=${props.conversation_id}`}>
|
||||
<p className={styles.session}>{props.slug || "New Conversation 🌱"}</p>
|
||||
</Link>
|
||||
<ChatSessionActionMenu conversationId={props.conversation_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatSessionsModalProps {
|
||||
data: ChatHistory[];
|
||||
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
data: GroupedChatHistory | null;
|
||||
}
|
||||
|
||||
function ChatSessionsModal({data, setIsExpanded}: ChatSessionsModalProps) {
|
||||
function ChatSessionsModal({ data }: ChatSessionsModalProps) {
|
||||
return (
|
||||
<div className={styles.modalSessionsList}>
|
||||
<div className={styles.content}>
|
||||
{data.map((chatHistory) => (
|
||||
<ChatSession key={chatHistory.conversation_id} conversation_id={chatHistory.conversation_id} slug={chatHistory.slug} />
|
||||
))}
|
||||
<button className={styles.showMoreButton} onClick={() => setIsExpanded(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
className="flex text-left text-medium text-gray-500 hover:text-gray-300 cursor-pointer my-4 text-sm p-[0.5rem]">
|
||||
<span className="mr-2">See All <ArrowRight className="h-4 w-4" /></span>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>All Conversations</DialogTitle>
|
||||
<DialogDescription
|
||||
className="p-0">
|
||||
<ScrollArea className="h-[500px] p-4">
|
||||
{data && Object.keys(data).map((timeGrouping) => (
|
||||
<div key={timeGrouping}>
|
||||
<div className={`text-muted-foreground text-sm font-bold p-[0.5rem] `}>
|
||||
{timeGrouping}
|
||||
</div>
|
||||
{data[timeGrouping].map((chatHistory) => (
|
||||
<ChatSession
|
||||
created={chatHistory.created}
|
||||
compressed={false}
|
||||
key={chatHistory.conversation_id}
|
||||
conversation_id={chatHistory.conversation_id}
|
||||
slug={chatHistory.slug}
|
||||
agent_avatar={chatHistory.agent_avatar}
|
||||
agent_name={chatHistory.agent_name} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidePanel() {
|
||||
interface UserProfileProps {
|
||||
userProfile: UserProfile;
|
||||
webSocketConnected?: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function UserProfileComponent(props: UserProfileProps) {
|
||||
if (props.collapsed) {
|
||||
return (
|
||||
<div className={styles.profile}>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={props.userProfile.photo} alt="user profile" />
|
||||
<AvatarFallback>
|
||||
{props.userProfile.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.profile}>
|
||||
<Link href="/config" target="_blank" rel="noopener noreferrer">
|
||||
<Avatar>
|
||||
<AvatarImage src={props.userProfile.photo} alt="user profile" />
|
||||
<AvatarFallback>
|
||||
{props.userProfile.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className={styles.profileDetails}>
|
||||
<p>{props.userProfile?.username}</p>
|
||||
{/* Connected Indicator */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className={`inline-flex h-4 w-4 rounded-full opacity-75 ${props.webSocketConnected ? 'bg-green-500' : 'bg-rose-500'}`}></div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.webSocketConnected ? "Connected" : "Disconnected"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
const fetchChatHistory = async (url: string) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const useChatSessionsFetchRequest = (url: string) => {
|
||||
const { data, error } = useSWR<ChatHistory[]>(url, fetchChatHistory);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
};
|
||||
|
||||
interface SidePanelProps {
|
||||
webSocketConnected?: boolean;
|
||||
conversationId: string | null;
|
||||
uploadedFiles: string[];
|
||||
}
|
||||
|
||||
|
||||
export default function SidePanel(props: SidePanelProps) {
|
||||
|
||||
const [data, setData] = useState<ChatHistory[] | null>(null);
|
||||
const [dataToShow, setDataToShow] = useState<ChatHistory[] | null>(null);
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [organizedData, setOrganizedData] = useState<GroupedChatHistory | null>(null);
|
||||
const [subsetOrganizedData, setSubsetOrganizedData] = useState<GroupedChatHistory | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(false);
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const authenticatedData = useAuthenticatedData();
|
||||
const { data: chatSessions } = useChatSessionsFetchRequest(authenticatedData ? `/api/chat/sessions` : '');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
fetch('/api/chat/sessions', { method: 'GET' })
|
||||
.then(response => response.json())
|
||||
.then((data: ChatHistory[]) => {
|
||||
setLoading(false);
|
||||
// Render chat options, if any
|
||||
if (data) {
|
||||
setData(data);
|
||||
setDataToShow(data.slice(0, 5));
|
||||
const [isMobileWidth, setIsMobileWidth] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatSessions) {
|
||||
setData(chatSessions);
|
||||
|
||||
const groupedData: GroupedChatHistory = {};
|
||||
const subsetOrganizedData: GroupedChatHistory = {};
|
||||
let numAdded = 0;
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
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));
|
||||
|
||||
const timeGrouping = diffDays < 7 ? 'Recent' : diffDays < 30 ? 'Last Month' : 'All Time';
|
||||
if (!groupedData[timeGrouping]) {
|
||||
groupedData[timeGrouping] = [];
|
||||
}
|
||||
groupedData[timeGrouping].push(chatHistory);
|
||||
|
||||
// Add to subsetOrganizedData if less than 8
|
||||
if (numAdded < 8) {
|
||||
if (!subsetOrganizedData[timeGrouping]) {
|
||||
subsetOrganizedData[timeGrouping] = [];
|
||||
}
|
||||
subsetOrganizedData[timeGrouping].push(chatHistory);
|
||||
numAdded++;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
|
||||
fetch('/api/v1/user', { method: 'GET' })
|
||||
.then(response => response.json())
|
||||
.then((data: UserProfile) => {
|
||||
setUserProfile(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.panel}`}>
|
||||
setSubsetOrganizedData(subsetOrganizedData);
|
||||
setOrganizedData(groupedData);
|
||||
}
|
||||
}, [chatSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobileWidth(true);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
setIsMobileWidth(window.innerWidth < 768);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.panel} ${enabled ? styles.expanded : styles.collapsed}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<Image src="/khoj-logo.svg"
|
||||
alt="logo"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
{
|
||||
authenticatedData &&
|
||||
isMobileWidth ?
|
||||
<Drawer>
|
||||
<DrawerTrigger><ArrowRight className="h-4 w-4 mx-2" /></DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Sessions and Files</DrawerTitle>
|
||||
<DrawerDescription>View all conversation sessions and manage conversation file filters</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className={`${styles.panelWrapper}`}>
|
||||
<SessionsAndFiles
|
||||
webSocketConnected={props.webSocketConnected}
|
||||
setEnabled={setEnabled}
|
||||
subsetOrganizedData={subsetOrganizedData}
|
||||
organizedData={organizedData}
|
||||
data={data}
|
||||
uploadedFiles={props.uploadedFiles}
|
||||
userProfile={authenticatedData}
|
||||
conversationId={props.conversationId}
|
||||
isMobileWidth={isMobileWidth}
|
||||
/>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
:
|
||||
<button className={styles.button} onClick={() => setEnabled(!enabled)}>
|
||||
{enabled ? <ArrowLeft className="h-4 w-4" /> : <ArrowRight className="h-4 w-4 mx-2" />}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
enabled ?
|
||||
<div>
|
||||
<div className={`${styles.expanded}`}>
|
||||
<div className={`${styles.profile}`}>
|
||||
{ userProfile &&
|
||||
<div className={styles.profile}>
|
||||
<img
|
||||
className={styles.profile}
|
||||
src={userProfile.photo}
|
||||
alt="profile"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<p>{userProfile?.username}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button className={styles.button} onClick={() => setEnabled(false)}>
|
||||
{/* Push Close Icon */}
|
||||
<svg fill="#000000" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" strokeWidth="0"></g><g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M8.70710678,12 L19.5,12 C19.7761424,12 20,12.2238576 20,12.5 C20,12.7761424 19.7761424,13 19.5,13 L8.70710678,13 L11.8535534,16.1464466 C12.0488155,16.3417088 12.0488155,16.6582912 11.8535534,16.8535534 C11.6582912,17.0488155 11.3417088,17.0488155 11.1464466,16.8535534 L7.14644661,12.8535534 C6.95118446,12.6582912 6.95118446,12.3417088 7.14644661,12.1464466 L11.1464466,8.14644661 C11.3417088,7.95118446 11.6582912,7.95118446 11.8535534,8.14644661 C12.0488155,8.34170876 12.0488155,8.65829124 11.8535534,8.85355339 L8.70710678,12 L8.70710678,12 Z M4,5.5 C4,5.22385763 4.22385763,5 4.5,5 C4.77614237,5 5,5.22385763 5,5.5 L5,19.5 C5,19.7761424 4.77614237,20 4.5,20 C4.22385763,20 4,19.7761424 4,19.5 L4,5.5 Z"></path> </g></svg>
|
||||
</button>
|
||||
<h3>Recent Conversations</h3>
|
||||
</div>
|
||||
<div className={styles.sessionsList}>
|
||||
{dataToShow && dataToShow.map((chatHistory) => (
|
||||
<ChatSession key={chatHistory.conversation_id} conversation_id={chatHistory.conversation_id} slug={chatHistory.slug} />
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
(data && data.length > 5) && (
|
||||
(isExpanded) ?
|
||||
<ChatSessionsModal data={data} setIsExpanded={setIsExpanded} />
|
||||
:
|
||||
<button className={styles.showMoreButton} onClick={() => {
|
||||
setIsExpanded(true);
|
||||
}}>
|
||||
Show All
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<div className={`${styles.collapsed}`}>
|
||||
{ userProfile &&
|
||||
<div className={`${styles.profile}`}>
|
||||
<img
|
||||
className={styles.profile}
|
||||
src={userProfile.photo}
|
||||
alt="profile"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<button className={styles.button} onClick={() => setEnabled(true)}>
|
||||
{/* Pull Open Icon */}
|
||||
<svg fill="#000000" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" strokeWidth="0"></g><g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.2928932,12 L12.1464466,8.85355339 C11.9511845,8.65829124 11.9511845,8.34170876 12.1464466,8.14644661 C12.3417088,7.95118446 12.6582912,7.95118446 12.8535534,8.14644661 L16.8535534,12.1464466 C17.0488155,12.3417088 17.0488155,12.6582912 16.8535534,12.8535534 L12.8535534,16.8535534 C12.6582912,17.0488155 12.3417088,17.0488155 12.1464466,16.8535534 C11.9511845,16.6582912 11.9511845,16.3417088 12.1464466,16.1464466 L15.2928932,13 L4.5,13 C4.22385763,13 4,12.7761424 4,12.5 C4,12.2238576 4.22385763,12 4.5,12 L15.2928932,12 Z M19,5.5 C19,5.22385763 19.2238576,5 19.5,5 C19.7761424,5 20,5.22385763 20,5.5 L20,19.5 C20,19.7761424 19.7761424,20 19.5,20 C19.2238576,20 19,19.7761424 19,19.5 L19,5.5 Z"></path> </g></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
authenticatedData && enabled &&
|
||||
<div className={`${styles.panelWrapper}`}>
|
||||
<SessionsAndFiles
|
||||
webSocketConnected={props.webSocketConnected}
|
||||
setEnabled={setEnabled}
|
||||
subsetOrganizedData={subsetOrganizedData}
|
||||
organizedData={organizedData}
|
||||
data={data}
|
||||
uploadedFiles={props.uploadedFiles}
|
||||
userProfile={authenticatedData}
|
||||
conversationId={props.conversationId}
|
||||
isMobileWidth={isMobileWidth}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
{
|
||||
!authenticatedData && enabled &&
|
||||
<div className={`${styles.panelWrapper}`}>
|
||||
<Link href="/">
|
||||
<Button variant="ghost"><House className="h-4 w-4 mr-1" />Home</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button variant="ghost"><StackPlus className="h-4 w-4 mr-1" />New Conversation</Button>
|
||||
</Link>
|
||||
<Link href={`/login?next=${encodeURIComponent(window.location.pathname)}`}> {/* Redirect to login page */}
|
||||
<Button variant="default"><UserCirclePlus className="h-4 w-4 mr-1"/>Sign Up</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,28 @@ div.session {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--main-text-color);
|
||||
cursor: pointer;
|
||||
max-width: 14rem;
|
||||
font-size: medium;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(auto, 350px) 1fr;
|
||||
}
|
||||
|
||||
div.compressed {
|
||||
grid-template-columns: minmax(auto, 12rem) 1fr 1fr;
|
||||
}
|
||||
|
||||
div.sessionHover {
|
||||
background-color: hsla(var(--popover));
|
||||
}
|
||||
|
||||
div.session:hover {
|
||||
background-color: hsla(var(--popover));
|
||||
color: hsla(var(--popover-foreground));
|
||||
}
|
||||
|
||||
div.session a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.button {
|
||||
@@ -17,29 +36,23 @@ button.button {
|
||||
}
|
||||
|
||||
button.showMoreButton {
|
||||
background: var(--intense-green);
|
||||
border: none;
|
||||
color: var(--frosted-background-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
div.panel {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--calm-blue);
|
||||
color: var(--main-text-color);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
max-width: auto;
|
||||
transition: background-color 0.5s;
|
||||
}
|
||||
|
||||
div.expanded {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1rem;
|
||||
background-color: hsla(var(--muted));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.collapsed {
|
||||
@@ -47,14 +60,12 @@ div.collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
div.session:hover {
|
||||
background-color: var(--calmer-blue);
|
||||
}
|
||||
|
||||
p.session {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
div.header {
|
||||
@@ -62,10 +73,18 @@ div.header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
img.profile {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
div.profile {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
div.panelWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +94,7 @@ div.modalSessionsList {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--frosted-background-color);
|
||||
background-color: hsla(var(--frosted-background-color));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -86,7 +105,7 @@ div.modalSessionsList {
|
||||
div.modalSessionsList div.content {
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
background-color: var(--frosted-background-color);
|
||||
background-color: hsla(var(--frosted-background-color));
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
@@ -96,3 +115,33 @@ div.modalSessionsList div.session {
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
div.panel {
|
||||
padding: 0.5rem;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.expanded {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
div.singleReference {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
div.panelWrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.session.compressed {
|
||||
max-width: 100%;
|
||||
grid-template-columns: minmax(auto, 350px) 1fr;
|
||||
}
|
||||
|
||||
div.session {
|
||||
max-width: 100%;
|
||||
grid-template-columns: 200px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user