Checkpoint: Updated sidebar panel with new components

- Add non-functional UI elements for chat, references, feedback buttons, rename/share session, mic, attachment, websocket connection
This commit is contained in:
sabaimran
2024-07-02 11:18:50 +05:30
parent c83b8f2768
commit 541ce04ebc
27 changed files with 1874 additions and 385 deletions

View File

@@ -96,7 +96,7 @@ function AgentModal(props: AgentModalProps) {
props.setShowModal(false);
}}>
<Image
src="Close.svg"
src="close.svg"
alt="Close"
width={24}
height={24} />

View File

@@ -12,23 +12,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);
grid-template-columns: auto 1fr auto auto;
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
margin-bottom: 20px;
gap: 12px;
padding-left: 20px;
padding-right: 20px;
}
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 {
@@ -65,9 +69,7 @@ div.chatLayout {
div.chatBox {
display: grid;
gap: 1rem;
height: 100%;
padding: 1rem;
}
div.titleBar {
@@ -75,6 +77,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 +105,22 @@ 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;
}
}

View File

@@ -6,54 +6,69 @@ 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 { setupWebSocket } from '../common/chatFunctions';
import 'katex/dist/katex.min.css';
import { Lightbulb, ArrowCircleUp, FileArrowUp, Microphone } from '@phosphor-icons/react';
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Button } from '@/components/ui/button';
export function TextareaWithLabel() {
return (
<div className="grid w-full gap-1.5">
{/* <Label htmlFor="message">Your message</Label> */}
<Textarea className='border-none min-h-[60px]' placeholder="Type / to see a list of commands" id="message" />
</div>
)
}
interface ChatOptions {
[key: string]: string
}
const styleClassOptions = ['pink', 'blue', 'green', 'yellow', 'purple'];
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
setTitle: (title: string) => void;
setConversationID?: (conversationId: string) => void;
}
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');
if (conversationId && props.setConversationID) {
props.setConversationID(conversationId);
}
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} />
}
return (
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory conversationId={conversationId} setTitle={props.setTitle} />
</div>
);
}
function Loading() {
return <h2>🌀 Loading...</h2>;
);
}
function handleChatInput(e: React.FormEvent<HTMLInputElement>) {
@@ -63,9 +78,12 @@ function handleChatInput(e: React.FormEvent<HTMLInputElement>) {
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('Chat');
const [conversationId, setConversationID] = useState<string | null>(null);
const [chatWS, setChatWS] = useState<WebSocket | null>(null);
useEffect(() => {
useEffect(() => {
fetch('/api/chat/options')
.then(response => response.json())
.then((data: ChatOptions) => {
@@ -80,27 +98,60 @@ export default function Chat() {
console.error(err);
return;
});
}, []);
}, []);
useEffect(() => {
(async () => {
if (conversationId) {
const newWS = await setupWebSocket(conversationId);
setChatWS(newWS);
}
})();
}, [conversationId]);
if (isLoading) {
return <Loading />;
}
return (
<div className={styles.main + " " + styles.chatLayout}>
<div className={styles.sidePanel}>
<SidePanel />
<SidePanel webSocketConnected={chatWS !== null} />
</div>
<title>
Khoj AI - Chat
</title>
<div className={styles.chatBox}>
<title>
Khoj AI - Chat
</title>
<NavMenu selected="Chat" />
<div>
<Suspense fallback={<Loading />}>
<ChatBodyData chatOptionsData={chatOptionsData} />
</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>
<NavMenu selected="Chat" title={title} />
<div className={styles.chatBoxBody}>
<div>
<Suspense fallback={<Loading />}>
<ChatBodyData chatOptionsData={chatOptionsData} setTitle={setTitle} setConversationID={setConversationID} />
</Suspense>
</div>
{/* <div className={styles.agentIndicator}>
<a className='no-underline' href="/agents?agent=khoj" target="_blank" rel="noreferrer">
<Lightbulb color='var(--khoj-orange)' weight='fill' />
<span className='text-neutral-600'>Khoj</span>
</a>
</div> */}
<div className={`${styles.inputBox} bg-background align-middle items-center justify-center`}>
<Button className="!bg-transparent !hover:bg-transparent p-0 h-auto text-3xl">
<FileArrowUp fill="hsla(var(--secondary-foreground))"/>
</Button>
<TextareaWithLabel />
<Button className="!bg-transparent !hover:bg-transparent p-0 h-auto text-3xl">
<Microphone fill="hsla(var(--secondary-foreground))"/>
</Button>
<Button className="bg-orange-300 hover:bg-orange-500 rounded-full p-0 h-auto text-3xl">
<ArrowCircleUp/>
</Button>
{/* <input className={styles.inputBox} type="text" placeholder="Type / to see a list of commands" onInput={(e) => handleChatInput(e)} /> */}
{/* <button className={styles.inputBox}>Send</button> */}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,110 @@
import { Context, OnlineContextData } from "../components/chatMessage/chatMessage";
interface ResponseWithReferences {
context?: Context[];
online?: {
[key: string]: OnlineContextData
}
response?: string;
}
function handleCompiledReferences(chunk: string, currentResponse: string) {
const rawReference = chunk.split("### compiled references:")[1];
const rawResponse = chunk.split("### compiled references:")[0];
let references: ResponseWithReferences = {};
// Set the initial response
references.response = currentResponse + rawResponse;
const rawReferenceAsJson = JSON.parse(rawReference);
if (rawReferenceAsJson instanceof Array) {
references.context = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
references.online = rawReferenceAsJson;
}
return references;
}
async function sendChatStream(
message: string,
conversationId: string,
setIsLoading: (loading: boolean) => void,
setInitialResponse: (response: string) => void,
setInitialReferences: (references: ResponseWithReferences) => void) {
setIsLoading(true);
// Send a message to the chat server to verify the fact
const chatURL = "/api/chat";
const apiURL = `${chatURL}?q=${encodeURIComponent(message)}&client=web&stream=true&conversation_id=${conversationId}`;
try {
const response = await fetch(apiURL);
if (!response.body) throw new Error("No response body found");
const reader = response.body?.getReader();
let decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
let chunk = decoder.decode(value, { stream: true });
if (chunk.includes("### compiled references:")) {
const references = handleCompiledReferences(chunk, result);
if (references.response) {
result = references.response;
setInitialResponse(references.response);
setInitialReferences(references);
}
} else {
result += chunk;
setInitialResponse(result);
}
}
} catch (error) {
console.error("Error verifying statement: ", error);
} finally {
setIsLoading(false);
}
}
export function sendChatWS(websocket: WebSocket, message: string) {
websocket.send(message);
}
export const setupWebSocket = async (conversationId: string) => {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = process.env.NODE_ENV === 'production' ? window.location.host : 'localhost:42110';
let webSocketUrl = `${wsProtocol}//${host}/api/chat/ws`;
if (conversationId === null) return null;
if (conversationId) {
webSocketUrl += `?conversation_id=${conversationId}`;
}
console.log("WebSocket URL: ", webSocketUrl);
const chatWS = new WebSocket(webSocketUrl);
chatWS.onopen = () => {
console.log('WebSocket connection established');
};
chatWS.onmessage = (event) => {
console.log(event.data);
};
chatWS.onerror = (error) => {
console.error('WebSocket error: ', error);
};
chatWS.onclose = () => {
console.log('WebSocket connection closed');
};
return chatWS;
};

View File

@@ -7,6 +7,16 @@ 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;
}

View File

@@ -5,10 +5,18 @@ import { useRef, useEffect, useState } from 'react';
import ChatMessage, { ChatHistoryData, SingleChatMessage } from '../chatMessage/chatMessage';
import ReferencePanel, { hasValidReferences} from '../referencePanel/referencePanel';
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 from '../loading/loading';
import { Lightbulb } from "@phosphor-icons/react";
interface ChatResponse {
status: string;
response: ChatHistoryData;
@@ -20,8 +28,7 @@ interface ChatHistory {
interface ChatHistoryProps {
conversationId: string;
setReferencePanelData: Function;
setShowReferencePanel: Function;
setTitle: (title: string) => void;
}
@@ -31,6 +38,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
const ref = useRef<HTMLDivElement>(null);
const chatHistoryRef = useRef(null);
const [showReferencePanel, setShowReferencePanel] = useState(true);
const [referencePanelData, setReferencePanelData] = useState<SingleChatMessage | null>(null);
useEffect(() => {
@@ -38,10 +47,12 @@ export default function ChatHistory(props: ChatHistoryProps) {
.then(response => response.json())
.then((chatData: ChatResponse) => {
setLoading(false);
// Render chat options, if any
if (chatData) {
console.log(chatData);
setData(chatData.response);
props.setTitle(chatData.response.slug);
}
})
.catch(err => {
@@ -55,7 +66,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
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) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Call your function here
renderMathInElement(document.body, {
delimiters: [
@@ -78,23 +89,50 @@ export default function ChatHistory(props: ChatHistoryProps) {
}, []);
if (isLoading) {
return <h2>🌀 Loading...</h2>;
return <Loading />;
}
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;
}
return (
<div className={styles.main + " " + styles.chatLayout}>
<ScrollArea className={`h-[80vh]`}>
<div ref={ref}>
<div className={styles.chatHistory} ref={chatHistoryRef}>
{(data && data.chat) && data.chat.map((chatMessage, index) => (
<ChatMessage
key={index}
chatMessage={chatMessage}
setReferencePanelData={props.setReferencePanelData}
setShowReferencePanel={props.setShowReferencePanel}
setReferencePanelData={setReferencePanelData}
setShowReferencePanel={setShowReferencePanel}
customClassName='fullHistory'
borderLeftColor='orange-400'
/>
))}
{
(hasValidReferences(referencePanelData) && showReferencePanel) &&
<ReferencePanel referencePanelData={referencePanelData} setShowReferencePanel={setShowReferencePanel} />
}
<div className={`${styles.agentIndicator}`}>
<a className='no-underline mx-2 flex' href={constructAgentLink()} target="_blank" rel="noreferrer">
<Lightbulb color='orange' weight='fill' />
<span className='text-neutral-600'>{constructAgentName()}</span>
</a>
</div>
</div>
</div>
</div>
</ScrollArea>
)
}

View File

@@ -1,12 +1,34 @@
div.chatMessageContainer {
display: flex;
flex-direction: column;
margin: 12px;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
}
div.chatMessageWrapper {
padding-left: 24px;
}
div.khojfullHistory {
border-color: var(--border-color);
border-width: 1px;
padding-left: 24px;
}
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: var(--frosted-background-color);
align-self: flex-end;
border-radius: 16px;
}
div.khoj {
@@ -15,6 +37,11 @@ div.khoj {
align-self: flex-start;
}
div.khojChatMessage {
padding-top: 8px;
padding-left: 16px;
}
div.chatMessageContainer img {
width: 50%;
}
@@ -42,21 +69,25 @@ div.chatFooter {
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(--background));
}
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 {
@@ -70,7 +101,6 @@ button.codeCopyButton {
}
button.codeCopyButton:hover {
background-color: var(--intense-green);
color: var(--frosted-background-color);
}
@@ -78,3 +108,10 @@ div.feedbackButtons img,
button.copyButton img {
width: 24px;
}
@media screen and (max-width: 768px) {
div.youfullHistory {
max-width: 100%;
}
}

View File

@@ -12,6 +12,8 @@ import 'highlight.js/styles/github.css'
import { hasValidReferences } from '../referencePanel/referencePanel';
import { ThumbsUp, ThumbsDown, Copy } from '@phosphor-icons/react';
const md = new markdownIt({
html: true,
linkify: true,
@@ -103,31 +105,19 @@ export interface ChatHistoryData {
function FeedbackButtons() {
return (
<div className={styles.feedbackButtons}>
<div className={`${styles.feedbackButtons} flex align-middle justify-center items-center`}>
<button className={styles.thumbsUpButton}>
<Image
src="/thumbs-up.svg"
alt="Thumbs Up"
width={24}
height={24}
priority
/>
<ThumbsUp color='hsl(var(--muted-foreground))' />
</button>
<button className={styles.thumbsDownButton}>
<Image
src="/thumbs-down.svg"
alt="Thumbs Down"
width={24}
height={24}
priority
/>
<ThumbsDown color='hsl(var(--muted-foreground))' />
</button>
</div>
)
}
function onClickMessage(event: React.MouseEvent<any>, chatMessage: SingleChatMessage, setReferencePanelData: Function, setShowReferencePanel: Function) {
event.preventDefault();
console.log("Clicked on message", chatMessage);
setReferencePanelData(chatMessage);
setShowReferencePanel(true);
}
@@ -136,6 +126,8 @@ interface ChatMessageProps {
chatMessage: SingleChatMessage;
setReferencePanelData: Function;
setShowReferencePanel: Function;
customClassName?: string;
borderLeftColor?: string;
}
export default function ChatMessage(props: ChatMessageProps) {
@@ -204,53 +196,65 @@ export default function ChatMessage(props: ChatMessageProps) {
let referencesValid = hasValidReferences(props.chatMessage);
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") {
classes.push(`border-l-4 border-opacity-50 border-l-orange-500 border-l-${props.borderLeftColor}`);
}
return classes.join(' ');
}
return (
<div
className={`${styles.chatMessageContainer} ${styles[props.chatMessage.by]}`}
className={constructClasses(props.chatMessage)}
onClick={props.chatMessage.by === "khoj" ? (event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel) : undefined}>
{/* <div className={styles.chatFooter}> */}
{/* {props.chatMessage.by} */}
{/* </div> */}
<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>
}
<button className={`${styles.copyButton}`} onClick={() => {
navigator.clipboard.writeText(props.chatMessage.message);
setCopySuccess(true);
}}>
<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}>
{
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
/>
referencesValid &&
<div className={styles.referenceButton}>
<button onClick={(event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel)}>
References
</button>
</div>
}
</button>
{
props.chatMessage.by === "khoj" && <FeedbackButtons />
}
<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" && <FeedbackButtons />
}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
/* HTML: <div class="loader"></div> */
.loader {
--c: conic-gradient(from -90deg, hsla(var(--secondary)) 90deg, #0000 0);
background: var(--c), var(--c);
background-size: 40% 40%;
animation: l19 1s infinite alternate;
}
@keyframes l19 {
0%,
10% {
background-position: 0 0, 0 calc(100%/3)
}
50% {
background-position: 0 0, calc(100%/3) calc(100%/3)
}
90%,
100% {
background-position: 0 0, calc(100%/3) 0
}
}

View File

@@ -0,0 +1,26 @@
import styles from './loading.module.css';
export default function Loading() {
// return (
// <div className={`${styles.loading} h-[100vh] flex items-center justify-center`}>
// <button type="button" className="bg-indigo-500" disabled>
// Loading...
// <span className="relative flex h-3 w-3">
// <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
// <span className="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
// </span>
// </button>
// </div>
// )
return (
<div className={`${styles.loader} h-[100vh] flex items-center justify-center`}></div>
);
return (
<div className="h-[100vh] flex items-center justify-center">
<h2 className="text-4xl text-black animate-bounce">
Loading...
</h2>
</div>
)
}

View File

@@ -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;
}
}

View File

@@ -1,106 +1,125 @@
'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";
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);
useEffect(() => {
setIsMobileWidth(window.innerWidth < 768);
setDisplayTitle(props.title || props.selected.toUpperCase());
}, [props.title]);
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'>
<MenubarMenu>
<Link href='/chat' className={`${props.selected.toLowerCase() === 'chat' ? styles.selected : ''} hover:bg-background`}>
<MenubarTrigger>Chat</MenubarTrigger>
</Link>
</MenubarMenu>
<MenubarMenu>
<Link href='/agents' className={`${props.selected.toLowerCase() === 'agent' ? styles.selected : ''} hover:bg-background`}>
<MenubarTrigger>Agents</MenubarTrigger>
</Link>
</MenubarMenu>
<MenubarMenu>
<Link href='/automations' className={`${props.selected.toLowerCase() === 'automations' ? styles.selected : ''} hover:bg-background`}>
<MenubarTrigger>Automations</MenubarTrigger>
</Link>
</MenubarMenu>
{userData &&
<MenubarMenu>
<MenubarTrigger>Profile</MenubarTrigger>
<MenubarContent>
<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>
)
}

View File

@@ -8,7 +8,6 @@ div.panel {
}
div.panel a {
color: var(--intense-green);
text-decoration: underline;
}
@@ -29,3 +28,13 @@ div.singleReference {
background-color: var(--frosted-background-color);
margin-top: 8px;
}
@media screen and (max-width: 768px) {
div.panel {
padding: 0.5rem;
}
div.singleReference {
padding: 4px;
}
}

View File

@@ -26,7 +26,7 @@ export function hasValidReferences(referencePanelData: SingleChatMessage | null)
(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))
(onlineContextData.webpages && onlineContextData.webpages.length > 0) || onlineContextData.answerBox || onlineContextData.peopleAlsoAsk || onlineContextData.knowledgeGraph || onlineContextData.organic ))
)
);
}
@@ -98,6 +98,7 @@ function OnlineReferences(props: { onlineContext: OnlineContextData, query: stri
const answerBox = props.onlineContext.answerBox;
const peopleAlsoAsk = props.onlineContext.peopleAlsoAsk;
const knowledgeGraph = props.onlineContext.knowledgeGraph;
const organic = props.onlineContext.organic;
return (
<div className={styles.singleReference}>
@@ -126,6 +127,24 @@ function OnlineReferences(props: { onlineContext: OnlineContextData, query: stri
</div>
)
}
{
organic && organic.map((organicData, index) => {
return (
<div className={styles.onlineReference} key={index}>
<div className={styles.onlineReferenceTitle}>
<a href={organicData.link} target="_blank" rel="noreferrer">
{organicData.title}
</a>
</div>
<div className={styles.onlineReferenceContent}>
<div>
{organicData.snippet}
</div>
</div>
</div>
)
})
}
{
peopleAlsoAsk && peopleAlsoAsk.map((people, index) => {
return (

View File

@@ -5,69 +5,254 @@ import styles from "./sidePanel.module.css";
import { useEffect, useState } from "react";
import { UserProfile } from "@/app/common/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ChatHistory {
conversation_id: string;
slug: string;
agent_name: string;
agent_avatar: string;
compressed: boolean;
}
function ChatSession(prop: ChatHistory) {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Pencil, Trash, Share } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
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: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(err => {
console.error(err);
return;
});
}
function shareConversation(conversationId: string) {
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 => {
console.log(data);
})
.catch(err => {
console.error(err);
return;
});
}
function deleteConversation(conversationId: string) {
const deleteUrl = `/api/chat/delete?client=web&conversation_id=${conversationId}`;
fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(err => {
console.error(err);
return;
});
}
interface ChatSessionActionMenuProps {
conversationId: string;
}
function ChatSessionActionMenu(props: ChatSessionActionMenuProps) {
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>
<DropdownMenu>
<DropdownMenuTrigger>:</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button className="p-0 text-sm h-auto" variant={'ghost'} onClick={() => renameConversation(props.conversationId, 'New Title')}>
<Pencil className="mr-2 h-4 w-4" />Rename
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Button className="p-0 text-sm h-auto" variant={'ghost'} onClick={() => shareConversation(props.conversationId)}>
<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={() => deleteConversation(props.conversationId)}>
<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-900 cursor-pointer">
Show All
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>All Conversations</DialogTitle>
<DialogDescription>
<ScrollArea className="h-[500px] w-[450px] rounded-md border p-4">
{data && Object.keys(data).map((agentName) => (
<div key={agentName}>
<h3 className={`grid grid-flow-col auto-cols-max gap-2`}>
<img src={data[agentName][0].agent_avatar} alt={agentName} width={24} height={24} />
{agentName}
</h3>
{data[agentName].map((chatHistory) => (
<ChatSession
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() {
const fetchChatHistory = async (url: string) => {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
};
export const useChatHistoryRecentFetchRequest = (url: string) => {
const { data, error } = useSWR<ChatHistory[]>(url, fetchChatHistory);
return {
data,
isLoading: !error && !data,
isError: error,
};
};
interface SidePanelProps {
webSocketConnected?: boolean;
}
export default function SidePanel(props: SidePanelProps) {
const [data, setData] = useState<ChatHistory[] | null>(null);
const [dataToShow, setDataToShow] = useState<ChatHistory[] | null>(null);
const [organizedData, setOrganizedData] = useState<GroupedChatHistory | null>(null);
const [subsetOrganizedData, setSubsetOrganizedData] = useState<GroupedChatHistory | null>(null);
const [isLoading, setLoading] = useState(true)
const [enabled, setEnabled] = useState(false);
const [isExpanded, setIsExpanded] = useState<boolean>(false);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
useEffect(() => {
const { data: chatHistory } = useChatHistoryRecentFetchRequest('/api/chat/sessions');
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));
useEffect(() => {
if (chatHistory) {
setData(chatHistory);
const groupedData: GroupedChatHistory = {};
const subsetOrganizedData: GroupedChatHistory = {};
let numAdded = 0;
chatHistory.forEach((chatHistory) => {
if (!groupedData[chatHistory.agent_name]) {
groupedData[chatHistory.agent_name] = [];
}
groupedData[chatHistory.agent_name].push(chatHistory);
// Add to subsetOrganizedData if less than 8
if (numAdded < 8) {
if (!subsetOrganizedData[chatHistory.agent_name]) {
subsetOrganizedData[chatHistory.agent_name] = [];
}
subsetOrganizedData[chatHistory.agent_name].push(chatHistory);
numAdded++;
}
})
.catch(err => {
console.error(err);
return;
});
setSubsetOrganizedData(subsetOrganizedData);
setOrganizedData(groupedData);
}
}, [chatHistory]);
useEffect(() => {
fetch('/api/v1/user', { method: 'GET' })
.then(response => response.json())
@@ -78,66 +263,79 @@ export default function SidePanel() {
console.error(err);
return;
});
}, []);
}, []);
return (
<div className={`${styles.panel}`}>
return (
<div className={`${styles.panel}`}>
{
enabled ?
<div>
<div className={`${styles.panelWrapper}`}>
<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>
<ScrollArea className="h-[40vh] w-[14rem]">
<div className={styles.sessionsList}>
{subsetOrganizedData && Object.keys(subsetOrganizedData).map((agentName) => (
<div key={agentName} className={`my-4`}>
<h3 className={`grid grid-flow-col auto-cols-max gap-2 my-4 font-bold text-sm`}>
<img src={subsetOrganizedData[agentName][0].agent_avatar} alt={agentName} width={24} height={24} />
{agentName}
</h3>
{subsetOrganizedData[agentName].map((chatHistory) => (
<ChatSession
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>
{
(data && data.length > 5) && (
(isExpanded) ?
<ChatSessionsModal data={data} setIsExpanded={setIsExpanded} />
:
<button className={styles.showMoreButton} onClick={() => {
setIsExpanded(true);
}}>
Show All
</button>
<ChatSessionsModal data={organizedData} />
)
}
{userProfile &&
<div className={styles.profile}>
<Avatar>
<AvatarImage src={userProfile.photo} alt="user profile" />
<AvatarFallback>
{userProfile.username[0]}
</AvatarFallback>
</Avatar>
<div className={styles.profileDetails}>
<p>{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>
}
</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>
}
{userProfile &&
<div className={styles.profile}>
<Avatar className="max-w-6 max-h-6 rounded-full">
<AvatarImage src={userProfile.photo} alt="user profile" />
<AvatarFallback>
{userProfile.username[0]}
</AvatarFallback>
</Avatar>
</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>
@@ -146,6 +344,6 @@ export default function SidePanel() {
</div>
}
</div>
);
</div>
);
}

View File

@@ -2,9 +2,27 @@ 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: var(--secondary-accent);
}
div.session:hover {
background-color: var(--secondary-accent);
}
div.session a {
text-decoration: none;
}
button.button {
@@ -17,9 +35,6 @@ button.button {
}
button.showMoreButton {
background: var(--intense-green);
border: none;
color: var(--frosted-background-color);
border-radius: 0.5rem;
padding: 8px;
}
@@ -28,9 +43,8 @@ div.panel {
display: grid;
grid-auto-flow: row;
padding: 1rem;
border-radius: 1rem;
background-color: var(--calm-blue);
color: var(--main-text-color);
background-color: hsla(var(--primary-foreground));
background-color: var(--secondary-background-color);
height: 100%;
overflow-y: auto;
max-width: auto;
@@ -47,14 +61,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 +74,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;
align-self: flex-end;
margin-top: auto;
}
div.panelWrapper {
display: flex;
flex-direction: column;
}
@@ -96,3 +116,13 @@ div.modalSessionsList div.session {
max-width: 100%;
text-overflow: ellipsis;
}
@media screen and (max-width: 768px) {
div.panel {
padding: 0.5rem;
}
div.singleReference {
padding: 4px;
}
}

View File

@@ -26,7 +26,14 @@
--ring: 209.1 100% 40.8%;
--radius: 0.5rem;
--font-family: "Noto Sans", "Noto Sans Arabic", sans-serif !important;
/* Khoj Custom Colors */
--primary-hover: #fee285;
--frosted-background-color: #F5F3F2;
--secondary-background-color: #F7F7F5;
--secondary-accent: #EDEDED;
--khoj-orange: #FFE7D1;
--border-color: #e2e2e2;
}
.dark {