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:
sabaimran
2024-07-14 10:48:06 -07:00
committed by GitHub
parent 02658ad4fd
commit 06dce4729b
46 changed files with 5652 additions and 954 deletions

View File

@@ -1,6 +1,6 @@
div.main {
height: 100vh;
color: black;
color: hsla(var(--foreground));
}
.suggestions {
@@ -11,24 +11,27 @@ div.main {
}
div.inputBox {
display: grid;
grid-template-columns: 1fr auto;
padding: 1rem;
border-radius: 1rem;
background-color: #f5f5f5;
box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 4px 10px var(--box-shadow-color);
margin-bottom: 20px;
gap: 12px;
padding-left: 20px;
padding-right: 20px;
align-content: center;
}
input.inputBox {
border: none;
}
input.inputBox:focus {
outline: none;
background-color: transparent;
}
input.inputBox:focus {
border: none;
outline: none;
background-color: transparent;
div.inputBox:focus {
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
}
div.chatBodyFull {
@@ -54,7 +57,7 @@ div.chatBody {
}
.inputBox {
color: black;
color: hsla(var(--foreground));
}
div.chatLayout {
@@ -65,9 +68,7 @@ div.chatLayout {
div.chatBox {
display: grid;
gap: 1rem;
height: 100%;
padding: 1rem;
}
div.titleBar {
@@ -75,6 +76,25 @@ div.titleBar {
grid-template-columns: 1fr auto;
}
div.chatBoxBody {
display: grid;
height: 100%;
width: 70%;
margin: auto;
}
div.agentIndicator a {
display: flex;
text-align: center;
align-content: center;
align-items: center;
}
div.agentIndicator {
padding: 10px;
}
@media (max-width: 768px) {
div.chatBody {
grid-template-columns: 0fr 1fr;
@@ -84,3 +104,23 @@ div.titleBar {
padding: 0;
}
}
@media screen and (max-width: 768px) {
div.inputBox {
margin-bottom: 0px;
}
div.chatBoxBody {
width: 100%;
}
div.chatBox {
padding: 0;
}
div.chatLayout {
gap: 0;
grid-template-columns: 1fr;
}
}

View File

@@ -16,6 +16,15 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<meta httpEquiv="Content-Security-Policy"
content="default-src 'self' https://assets.khoj.dev;
script-src 'self' https://assets.khoj.dev 'unsafe-inline' 'unsafe-eval';
connect-src 'self' https://ipapi.co/json ws://localhost:42110;
style-src 'self' https://assets.khoj.dev 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://*.khoj.dev https://*.googleusercontent.com https://*.google.com/ https://*.gstatic.com;
font-src 'self' https://assets.khoj.dev https://fonts.gstatic.com;
child-src 'none';
object-src 'none';"></meta>
<body className={inter.className}>
{children}
</body>

View File

@@ -6,73 +6,193 @@ import React, { Suspense, useEffect, useState } from 'react';
import SuggestionCard from '../components/suggestions/suggestionCard';
import SidePanel from '../components/sidePanel/chatHistorySidePanel';
import ChatHistory from '../components/chatHistory/chatHistory';
import { SingleChatMessage } from '../components/chatMessage/chatMessage';
import NavMenu from '../components/navMenu/navMenu';
import { useSearchParams } from 'next/navigation'
import ReferencePanel, { hasValidReferences } from '../components/referencePanel/referencePanel';
import Loading from '../components/loading/loading';
import { handleCompiledReferences, handleImageResponse, setupWebSocket } from '../common/chatFunctions';
import 'katex/dist/katex.min.css';
interface ChatOptions {
[key: string]: string
}
import { StreamMessage } from '../components/chatMessage/chatMessage';
import { welcomeConsole } from '../common/utils';
import ChatInputArea, { ChatOptions } from '../components/chatInputArea/chatInputArea';
import { useAuthenticatedData } from '../common/auth';
const styleClassOptions = ['pink', 'blue', 'green', 'yellow', 'purple'];
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
setTitle: (title: string) => void;
onConversationIdChange?: (conversationId: string) => void;
setQueryToProcess: (query: string) => void;
streamedMessages: StreamMessage[];
setUploadedFiles: (files: string[]) => void;
isMobileWidth?: boolean;
isLoggedIn: boolean;
}
function ChatBodyData({ chatOptionsData }: { chatOptionsData: ChatOptions | null }) {
const searchParams = useSearchParams();
const conversationId = searchParams.get('conversationId');
const [showReferencePanel, setShowReferencePanel] = useState(true);
const [referencePanelData, setReferencePanelData] = useState<SingleChatMessage | null>(null);
function ChatBodyData(props: ChatBodyDataProps) {
const searchParams = useSearchParams();
const conversationId = searchParams.get('conversationId');
const [message, setMessage] = useState('');
const [processingMessage, setProcessingMessage] = useState(false);
useEffect(() => {
if (conversationId) {
props.onConversationIdChange?.(conversationId);
}
}, [conversationId, props.onConversationIdChange]);
useEffect(() => {
if (message) {
setProcessingMessage(true);
props.setQueryToProcess(message);
}
}, [message]);
useEffect(() => {
if (props.streamedMessages &&
props.streamedMessages.length > 0 &&
props.streamedMessages[props.streamedMessages.length - 1].completed) {
setProcessingMessage(false);
} else {
setMessage('');
}
}, [props.streamedMessages]);
if (!conversationId) {
return (
<div className={styles.suggestions}>
{chatOptionsData && Object.entries(chatOptionsData).map(([key, value]) => (
{props.chatOptionsData && Object.entries(props.chatOptionsData).map(([key, value]) => (
<SuggestionCard
key={key}
title={`/${key}`}
body={value}
link='#' // replace with actual link if available
styleClass={styleClassOptions[Math.floor(Math.random() * styleClassOptions.length)]}
/>
/>
))}
</div>
);
}
return(
<div className={(hasValidReferences(referencePanelData) && showReferencePanel) ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory conversationId={conversationId} setReferencePanelData={setReferencePanelData} setShowReferencePanel={setShowReferencePanel} />
{
(hasValidReferences(referencePanelData) && showReferencePanel) &&
<ReferencePanel referencePanelData={referencePanelData} setShowReferencePanel={setShowReferencePanel} />
}
</div>
);
}
function Loading() {
return <h2>🌀 Loading...</h2>;
}
function handleChatInput(e: React.FormEvent<HTMLInputElement>) {
const target = e.target as HTMLInputElement;
console.log(target.value);
return (
<>
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory
conversationId={conversationId}
setTitle={props.setTitle}
pendingMessage={processingMessage ? message : ''}
incomingMessages={props.streamedMessages} />
</div>
<div className={`${styles.inputBox} bg-background align-middle items-center justify-center px-3`}>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={conversationId}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles} />
</div>
</>
);
}
export default function Chat() {
const [chatOptionsData, setChatOptionsData] = useState<ChatOptions | null>(null);
const [isLoading, setLoading] = useState(true)
const [isLoading, setLoading] = useState(true);
const [title, setTitle] = useState('Khoj AI - Chat');
const [conversationId, setConversationID] = useState<string | null>(null);
const [chatWS, setChatWS] = useState<WebSocket | null>(null);
const [messages, setMessages] = useState<StreamMessage[]>([]);
const [queryToProcess, setQueryToProcess] = useState<string>('');
const [processQuerySignal, setProcessQuerySignal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [isMobileWidth, setIsMobileWidth] = useState(false);
useEffect(() => {
const authenticatedData = useAuthenticatedData();
welcomeConsole();
const handleWebSocketMessage = (event: MessageEvent) => {
let chunk = event.data;
let currentMessage = messages.find(message => !message.completed);
if (!currentMessage) {
console.error("No current message found");
return;
}
// Process WebSocket streamed data
if (chunk === "start_llm_response") {
console.log("Started streaming", new Date());
} else if (chunk === "end_llm_response") {
currentMessage.completed = true;
} else {
// Get the current message
// Process and update state with the new message
if (chunk.includes("application/json")) {
chunk = JSON.parse(chunk);
}
const contentType = chunk["content-type"];
if (contentType === "application/json") {
try {
if (chunk.image || chunk.detail) {
let responseWithReference = handleImageResponse(chunk);
console.log("Image response", responseWithReference);
if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response;
if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online;
if (responseWithReference.context) currentMessage.context = responseWithReference.context;
} else if (chunk.type == "status") {
currentMessage.trainOfThought.push(chunk.message);
} else if (chunk.type == "rate_limit") {
console.log("Rate limit message", chunk);
currentMessage.rawResponse = chunk.message;
} else {
console.log("any message", chunk);
}
} catch (error) {
console.error("Error processing message", error);
currentMessage.completed = true;
} finally {
// no-op
}
} else {
// Update the current message with the new chunk
if (chunk && chunk.includes("### compiled references:")) {
let responseWithReference = handleCompiledReferences(chunk, "");
currentMessage.rawResponse += responseWithReference.response;
if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response;
if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online;
if (responseWithReference.context) currentMessage.context = responseWithReference.context;
} else {
// If the chunk is not a JSON object, just display it as is
currentMessage.rawResponse += chunk;
}
}
};
// Update the state with the new message, currentMessage
setMessages([...messages]);
}
useEffect(() => {
fetch('/api/chat/options')
.then(response => response.json())
.then((data: ChatOptions) => {
setLoading(false);
// Render chat options, if any
if (data) {
console.log(data);
setChatOptionsData(data);
}
})
@@ -80,28 +200,92 @@ export default function Chat() {
console.error(err);
return;
});
}, []);
setIsMobileWidth(window.innerWidth < 786);
window.addEventListener('resize', () => {
setIsMobileWidth(window.innerWidth < 786);
});
}, []);
useEffect(() => {
if (chatWS && queryToProcess) {
// Add a new object to the state
const newStreamMessage: StreamMessage = {
rawResponse: "",
trainOfThought: [],
context: [],
onlineContext: {},
completed: false,
timestamp: (new Date()).toISOString(),
rawQuery: queryToProcess || "",
}
setMessages(prevMessages => [...prevMessages, newStreamMessage]);
setProcessQuerySignal(true);
} else {
if (!chatWS) {
console.error("No WebSocket connection available");
}
if (!queryToProcess) {
console.error("No query to process");
}
}
}, [queryToProcess]);
useEffect(() => {
if (processQuerySignal && chatWS) {
setProcessQuerySignal(false);
chatWS.onmessage = handleWebSocketMessage;
chatWS?.send(queryToProcess);
}
}, [processQuerySignal]);
useEffect(() => {
(async () => {
if (conversationId) {
const newWS = await setupWebSocket(conversationId);
setChatWS(newWS);
}
})();
}, [conversationId]);
const handleConversationIdChange = (newConversationId: string) => {
setConversationID(newConversationId);
};
if (isLoading) {
return <Loading />;
}
return (
<div className={styles.main + " " + styles.chatLayout}>
<title>
{title}
</title>
<div className={styles.sidePanel}>
<SidePanel />
<SidePanel
webSocketConnected={chatWS !== null}
conversationId={conversationId}
uploadedFiles={uploadedFiles} />
</div>
<div className={styles.chatBox}>
<title>
Khoj AI - Chat
</title>
<NavMenu selected="Chat" />
<div>
<NavMenu selected="Chat" title={title} />
<div className={styles.chatBoxBody}>
<Suspense fallback={<Loading />}>
<ChatBodyData chatOptionsData={chatOptionsData} />
<ChatBodyData
isLoggedIn={authenticatedData !== null}
streamedMessages={messages}
chatOptionsData={chatOptionsData}
setTitle={setTitle}
setQueryToProcess={setQueryToProcess}
setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth}
onConversationIdChange={handleConversationIdChange} />
</Suspense>
</div>
<div className={styles.inputBox}>
<input className={styles.inputBox} type="text" placeholder="Type here..." onInput={(e) => handleChatInput(e)} />
<button className={styles.inputBox}>Send</button>
</div>
</div>
</div>
)