diff --git a/src/interface/web/app/agents/agents.module.css b/src/interface/web/app/agents/agents.module.css new file mode 100644 index 00000000..a02f24d8 --- /dev/null +++ b/src/interface/web/app/agents/agents.module.css @@ -0,0 +1,215 @@ +div.titleBar { + padding: 16px 0; + text-align: center; + font-size: larger; +} + +.agentPersonality p { + white-space: inherit; + overflow: hidden; + height: 78px; + line-height: 1.5; +} + +div.agentPersonality { + text-align: left; + grid-column: span 3; + overflow: hidden; +} + +div.agentInfo { + font-size: medium; +} + +div.agentInfo a, +div.agentInfo h2 { + margin: 0; +} + +div.agent img { + border-radius: 50%; + object-fit: cover; +} + +div.agent a { + text-decoration: none; + color: var(--main-text-color); +} + +div#agentsHeader { + display: grid; + grid-template-columns: auto; +} + +button.infoButton { + border: none; + background-color: transparent !important; + text-align: left; + font-family: inherit; + font-size: medium; +} + +div#agentsHeader a, +div.agentInfo button { + font-size: 24px; + font-weight: bold; + padding: 4px; + border: none; + border-radius: 8px; + background-color: var(--summer-sun); + font: inherit; + color: var(--main-text-color); + cursor: pointer; + transition: background-color 0.3s; +} + +div#agentsHeader a:hover, +div.agentInfo button:hover { + background-color: var(--primary-hover); + box-shadow: 0 0 10px var(--primary-hover); +} + +div.agent { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 20px; + align-items: center; + padding: 20px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + border-radius: 8px; + background: linear-gradient(18.48deg,rgba(252, 213, 87, 0.25) 2.76%,rgba(197, 0, 0, 0) 17.23%),linear-gradient(200.6deg,rgba(244, 229, 68, 0.25) 4.13%,rgba(230, 26, 26, 0) 20.54%); +} + +div.agentModal { + padding: 20px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + border-radius: 8px; + background: linear-gradient(18.48deg,rgba(252, 213, 87, 0.25) 2.76%,rgba(197, 0, 0, 0) 17.23%),linear-gradient(200.6deg,rgba(244, 229, 68, 0.25) 4.13%,rgba(230, 26, 26, 0) 20.54%); +} + +div.agentModalContent button { + width: 100%; + margin: 10px 0; + padding: 8px; +} + +div.agentModalHeader { + display: grid; + grid-template-columns: 1fr auto; +} + +div.agentAvatar { + display: flex; + align-items: center; + gap: 8px; +} + +div.agentModalContent p { + white-space: break-spaces; + line-height: 1.5; +} + +div.agentInfo { + text-align: left; +} + +div.agentList { + display: grid; + gap: 20px; + padding: 20px; + margin-right: auto; + grid-auto-flow: row; + grid-template-columns: 1fr 1fr; + margin-left: auto; +} + +svg.newConvoButton { + width: 20px; + margin-left: 5px; +} + +div.agentModalContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + margin: auto; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(1,1,1,0.5); +} + +div.agentModal { + position: relative; + width: 50%; + margin: auto; + padding: 20px; + background-color: white; + border-radius: 8px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); +} + +div.agentModalActions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +div.agentModalActions button { + padding: 8px; + border: none; + border-radius: 8px; + background-color: var(--summer-sun); + color: var(--main-text-color); + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +div.agentModalActions button:hover { + background-color: var(--primary-hover); + box-shadow: 0 0 10px var(--primary-hover); +} + +@media only screen and (max-width: 700px) { + div.agentList { + width: 90%; + margin-right: auto; + margin-left: auto; + grid-template-columns: 1fr; + } + +} +.loader { + width: 48px; + height: 48px; + border-radius: 50%; + display: inline-block; + border-top: 4px solid var(--primary-color); + border-right: 4px solid transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} +.loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + top: 0; + width: 48px; + height: 48px; + border-radius: 50%; + border-left: 4px solid var(--summer-sun); + border-bottom: 4px solid transparent; + animation: rotation 0.5s linear infinite reverse; +} +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/interface/web/app/agents/agentsLayout.module.css b/src/interface/web/app/agents/agentsLayout.module.css new file mode 100644 index 00000000..fd80922e --- /dev/null +++ b/src/interface/web/app/agents/agentsLayout.module.css @@ -0,0 +1,10 @@ +.agentsLayout { + max-width: 70vw; + margin: auto; +} + +@media screen and (max-width: 700px) { + .agentsLayout { + max-width: 90vw; + } +} diff --git a/src/interface/web/app/agents/layout.tsx b/src/interface/web/app/agents/layout.tsx new file mode 100644 index 00000000..7e8e3d59 --- /dev/null +++ b/src/interface/web/app/agents/layout.tsx @@ -0,0 +1,25 @@ + +import type { Metadata } from "next"; +import NavMenu from '../components/navMenu/navMenu'; +import styles from './agentsLayout.module.css'; + +export const metadata: Metadata = { + title: "Khoj AI - Agents", + description: "Use Agents with Khoj AI for deeper, more personalized queries.", + icons: { + icon: '/static/favicon.ico', + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/interface/web/app/agents/page.tsx b/src/interface/web/app/agents/page.tsx new file mode 100644 index 00000000..14413907 --- /dev/null +++ b/src/interface/web/app/agents/page.tsx @@ -0,0 +1,208 @@ +'use client' + +import styles from './agents.module.css'; + +import Image from 'next/image'; +import Link from 'next/link'; +import useSWR from 'swr'; + +import { useEffect, useState } from 'react'; + +import { useAuthenticatedData, UserProfile } from '../common/auth'; + + +export interface AgentData { + slug: string; + avatar: string; + name: string; + personality: string; +} + +async function openChat(slug: string, userData: UserProfile | null) { + + const unauthenticatedRedirectUrl = `/login?next=/agents?agent=${slug}`; + if (!userData) { + window.location.href = unauthenticatedRedirectUrl; + return; + } + + const response = await fetch(`/api/chat/sessions?agent_slug=${slug}`, { method: "POST" }); + // const data = await response.json(); + if (response.status == 200) { + window.location.href = `/chat`; + } else if(response.status == 403 || response.status == 401) { + window.location.href = unauthenticatedRedirectUrl; + } else { + alert("Failed to start chat session"); + } +} + +const agentsFetcher = () => window.fetch('/api/agents').then(res => res.json()).catch(err => console.log(err)); + +interface AgentModalProps { + data: AgentData; + setShowModal: (show: boolean) => void; + userData: UserProfile | null; +} + +interface AgentCardProps { + data: AgentData; + userProfile: UserProfile | null; +} + +function AgentModal(props: AgentModalProps) { + const [copiedToClipboard, setCopiedToClipboard] = useState(false); + + useEffect(() => { + if (copiedToClipboard) { + setTimeout(() => setCopiedToClipboard(false), 3000); + } + }, [copiedToClipboard]); + + return ( +
+
+
+
+
+ {props.data.name} +

{props.data.name}

+
+
+ + +
+
+

{props.data.personality}

+
+ +
+
+
+
+ ); +} + +function AgentCard(props: AgentCardProps) { + const searchParams = new URLSearchParams(window.location.search); + const agentSlug = searchParams.get('agent'); + const [showModal, setShowModal] = useState(agentSlug === props.data.slug); + + const userData = props.userProfile; + + if (showModal) { + window.history.pushState({}, `Khoj AI - Agent ${props.data.slug}`, `/agents?agent=${props.data.slug}`); + } + + return ( +
+ { + showModal && + } + +
+ {props.data.name} +
+ +
+ +
+
+ +
+
+ +
+
+ ); +} + +export default function Agents() { + const { data, error } = useSWR('agents', agentsFetcher, { revalidateOnFocus: false }); + const userData = useAuthenticatedData(); + + if (error) { + return ( +
+
+ Talk to a Specialized Agent +
+
+ Error loading agents +
+
+ ); + } + + if (!data) { + return ( +
+
+ Talk to a Specialized Agent +
+
+ Loading agents... +
+
+ ); + } + + return ( +
+
+ Talk to a Specialized Agent +
+
+ {data.map(agent => ( + + ))} +
+
+ ); +} diff --git a/src/interface/web/app/chat/chat.module.css b/src/interface/web/app/chat/chat.module.css new file mode 100644 index 00000000..56b15d30 --- /dev/null +++ b/src/interface/web/app/chat/chat.module.css @@ -0,0 +1,86 @@ +div.main { + height: 100vh; + color: black; +} + +.suggestions { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + justify-content: center; +} + +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); +} + +input.inputBox { + border: none; + outline: none; + background-color: transparent; +} + +input.inputBox:focus { + border: none; + outline: none; + background-color: transparent; +} + +div.chatBodyFull { + display: grid; + grid-template-columns: 1fr; + height: 100%; +} + +button.inputBox { + border: none; + outline: none; + background-color: transparent; + cursor: pointer; + border-radius: 0.5rem; + padding: 0.5rem; + background: linear-gradient(var(--calm-green), var(--calm-blue)); +} + +div.chatBody { + display: grid; + grid-template-columns: 1fr 1fr; + height: 100%; +} + +.inputBox { + color: black; +} + +div.chatLayout { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; +} + +div.chatBox { + display: grid; + gap: 1rem; + height: 100%; + padding: 1rem; +} + +div.titleBar { + display: grid; + grid-template-columns: 1fr auto; +} + +@media (max-width: 768px) { + div.chatBody { + grid-template-columns: 0fr 1fr; + } + + div.chatBox { + padding: 0; + } +} diff --git a/src/interface/web/app/chat/layout.tsx b/src/interface/web/app/chat/layout.tsx new file mode 100644 index 00000000..46161774 --- /dev/null +++ b/src/interface/web/app/chat/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { Noto_Sans } from "next/font/google"; +import "../globals.css"; + +const inter = Noto_Sans({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Khoj AI - Chat", + description: "Use this page to chat with Khoj AI.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx new file mode 100644 index 00000000..dbc7fc13 --- /dev/null +++ b/src/interface/web/app/chat/page.tsx @@ -0,0 +1,106 @@ +'use client' + +import styles from './chat.module.css'; +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 'katex/dist/katex.min.css'; + +interface ChatOptions { + [key: string]: string +} +const styleClassOptions = ['pink', 'blue', 'green', 'yellow', 'purple']; + + +function ChatBodyData({ chatOptionsData }: { chatOptionsData: ChatOptions | null }) { + const searchParams = useSearchParams(); + const conversationId = searchParams.get('conversationId'); + const [showReferencePanel, setShowReferencePanel] = useState(true); + const [referencePanelData, setReferencePanelData] = useState(null); + + if (!conversationId) { + return ( +
+ {chatOptionsData && Object.entries(chatOptionsData).map(([key, value]) => ( + + ))} +
+ ); + } + + return( +
+ + { + (hasValidReferences(referencePanelData) && showReferencePanel) && + + } +
+ ); +} + +function Loading() { + return

🌀 Loading...

; +} + +function handleChatInput(e: React.FormEvent) { + const target = e.target as HTMLInputElement; +} + +export default function Chat() { + const [chatOptionsData, setChatOptionsData] = useState(null); + const [isLoading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/chat/options') + .then(response => response.json()) + .then((data: ChatOptions) => { + setLoading(false); + // Render chat options, if any + if (data) { + setChatOptionsData(data); + } + }) + .catch(err => { + console.error(err); + return; + }); + }, []); + + + return ( +
+
+ +
+
+ + Khoj AI - Chat + + +
+ }> + + +
+
+ handleChatInput(e)} /> + +
+
+
+ ) +} diff --git a/src/interface/web/app/common/auth.ts b/src/interface/web/app/common/auth.ts new file mode 100644 index 00000000..45ac540b --- /dev/null +++ b/src/interface/web/app/common/auth.ts @@ -0,0 +1,23 @@ +'use client' + +import useSWR from 'swr' + +export interface UserProfile { + email: string; + username: string; + photo: string; + is_active: boolean; + has_documents: boolean; +} + +const userFetcher = () => window.fetch('/api/v1/user').then(res => res.json()).catch(err => console.log(err)); + +export function useAuthenticatedData() { + + const { data, error } = useSWR('/api/v1/user', userFetcher, { revalidateOnFocus: false }); + + if (error) return null; + if (!data) return null; + + return data; +} diff --git a/src/interface/web/app/common/chatFunctions.ts b/src/interface/web/app/common/chatFunctions.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/interface/web/app/components/chatHistory/chatHistory.module.css b/src/interface/web/app/components/chatHistory/chatHistory.module.css new file mode 100644 index 00000000..4597ae77 --- /dev/null +++ b/src/interface/web/app/components/chatHistory/chatHistory.module.css @@ -0,0 +1,12 @@ +div.chatHistory { + display: flex; + flex-direction: column; + height: 100%; +} + +div.chatLayout { + height: 80vh; + overflow-y: auto; + /* width: 80%; */ + margin: 0 auto; +} diff --git a/src/interface/web/app/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx new file mode 100644 index 00000000..225d4049 --- /dev/null +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -0,0 +1,99 @@ +'use client' + +import styles from './chatHistory.module.css'; +import { useRef, useEffect, useState } from 'react'; + +import ChatMessage, { ChatHistoryData, SingleChatMessage } from '../chatMessage/chatMessage'; + +import renderMathInElement from 'katex/contrib/auto-render'; +import 'katex/dist/katex.min.css'; +import 'highlight.js/styles/github.css' + +interface ChatResponse { + status: string; + response: ChatHistoryData; +} + +interface ChatHistory { + [key: string]: string +} + +interface ChatHistoryProps { + conversationId: string; + setReferencePanelData: Function; + setShowReferencePanel: Function; +} + + +export default function ChatHistory(props: ChatHistoryProps) { + const [data, setData] = useState(null); + const [isLoading, setLoading] = useState(true) + const ref = useRef(null); + const chatHistoryRef = useRef(null); + + + useEffect(() => { + + 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) { + setData(chatData.response); + } + }) + .catch(err => { + console.error(err); + return; + }); + }, [props.conversationId]); + + + 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) { + // Call your function here + renderMathInElement(document.body, { + delimiters: [ + { left: '$$', right: '$$', display: true }, + { left: '\\[', right: '\\]', display: true }, + { left: '$', right: '$', display: false }, + { left: '\\(', right: '\\)', display: false }, + ], + }); + } + } + }); + + if (chatHistoryRef.current) { + observer.observe(chatHistoryRef.current, { childList: true }); + } + + // Clean up the observer on component unmount + return () => observer.disconnect(); + }, []); + + if (isLoading) { + return

🌀 Loading...

; + } + + return ( +
+
+
+ {(data && data.chat) && data.chat.map((chatMessage, index) => ( + + ))} +
+
+
+ ) +} diff --git a/src/interface/web/app/components/chatMessage/chatMessage.module.css b/src/interface/web/app/components/chatMessage/chatMessage.module.css new file mode 100644 index 00000000..d9d28b6b --- /dev/null +++ b/src/interface/web/app/components/chatMessage/chatMessage.module.css @@ -0,0 +1,85 @@ +div.chatMessageContainer { + display: flex; + flex-direction: column; + margin: 0.5rem; + padding: 0.5rem; + border-radius: 0.5rem; + border: 1px solid black; + /* max-width: 80%; */ +} + +div.you { + color: var(--frosted-background-color); + background-color: var(--intense-green); + align-self: flex-end; +} + +div.khoj { + background-color: transparent; + color: #000000; + align-self: flex-start; +} + +div.chatMessageContainer img { + width: 50%; +} + +div.chatMessageContainer h3 img { + width: 24px; +} + +div.you .author { + color: var(--frosted-background-color); +} + +div.author { + font-size: 0.75rem; + color: #808080; + text-align: right; +} + +div.chatFooter { + display: flex; + justify-content: space-between; + margin-top: 8px; +} + +div.chatButtons { + display: flex; + justify-content: flex-end; +} + +div.chatFooter button { + cursor: pointer; + background-color: var(--calm-blue); + color: var(--main-text-color); + border: none; + border-radius: 0.5rem; + padding: 0.25rem; + margin-left: 0.5rem; +} + +div.chatFooter button:hover { + background-color: var(--frosted-background-color); + color: var(--intense-green); +} + +div.chatTimestamp { + font-size: small; +} + +button.codeCopyButton { + cursor: pointer; + float: right; + border-radius: 8px; +} + +button.codeCopyButton:hover { + background-color: var(--intense-green); + color: var(--frosted-background-color); +} + +div.feedbackButtons img, +button.copyButton img { + width: auto; +} diff --git a/src/interface/web/app/components/chatMessage/chatMessage.tsx b/src/interface/web/app/components/chatMessage/chatMessage.tsx new file mode 100644 index 00000000..450236be --- /dev/null +++ b/src/interface/web/app/components/chatMessage/chatMessage.tsx @@ -0,0 +1,258 @@ +"use client" + +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'; + +const md = new markdownIt({ + html: true, + linkify: true, + typographer: true +}); + +md.use(mditHljs, { + inline: true, + code: true +}); + +export interface Context { + compiled: string; + file: string; +} + +export interface WebPage { + link: string; + query: string; + snippet: string; +} + +interface OrganicContext { + snippet: string; + title: string; + link: string; +} + +interface PeopleAlsoAsk { + link: string; + question: string; + snippet: string; + title: string; +} + +export interface OnlineContextData { + webpages: WebPage[]; + answerBox: { + answer: string; + source: string; + title: string; + } + knowledgeGraph: { + attributes: { + [key: string]: string; + } + description: string; + descriptionLink: string; + descriptionSource: string; + imageUrl: string; + title: string; + type: string; + } + organic: OrganicContext[]; + peopleAlsoAsk: PeopleAlsoAsk[]; +} + +interface AgentData { + name: string; + avatar: string; + slug: string; +} + +interface Intent { + 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 + } +} + +export interface ChatHistoryData { + chat: SingleChatMessage[]; + agent: AgentData; + conversation_id: string; + slug: string; +} + +function FeedbackButtons() { + return ( +
+ + +
+ ) +} + +function onClickMessage(event: React.MouseEvent, chatMessage: SingleChatMessage, setReferencePanelData: Function, setShowReferencePanel: Function) { + event.preventDefault(); + setReferencePanelData(chatMessage); + setShowReferencePanel(true); +} + +interface ChatMessageProps { + chatMessage: SingleChatMessage; + setReferencePanelData: Function; + setShowReferencePanel: Function; +} + +export default function ChatMessage(props: ChatMessageProps) { + const [copySuccess, setCopySuccess] = useState(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 = `![generated_image](${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 messageRef = useRef(null); + + 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.alt = 'Copy'; + copyImage.width = 24; + copyImage.height = 24; + copyButton.appendChild(copyImage); + copyButton.className = `hljs ${styles.codeCopyButton}` + copyButton.addEventListener('click', () => { + let textContent = preElement.textContent || ''; + // Strip any leading $ characters + textContent = textContent.replace(/^\$+/, ''); + // Remove 'Copy' if it's at the start of the string + textContent = textContent.replace(/^Copy/, ''); + textContent = textContent.trim(); + navigator.clipboard.writeText(textContent); + }); + preElement.prepend(copyButton); + }); + } + }, [markdownRendered]); + + 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()}`; + } + + useEffect(() => { + if (copySuccess) { + setTimeout(() => { + setCopySuccess(false); + }, 2000); + } + }, [copySuccess]); + + let referencesValid = hasValidReferences(props.chatMessage); + + return ( +
onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel) : undefined}> + {/*
*/} + {/* {props.chatMessage.by} */} + {/*
*/} +
+ {/* Add a copy button, thumbs up, and thumbs down buttons */} +
+
+ {renderTimeStamp(props.chatMessage.created)} +
+
+ { + referencesValid && +
+ +
+ } + + { + props.chatMessage.by === "khoj" && + } +
+
+
+ ) +} diff --git a/src/interface/web/app/components/navMenu/navMenu.module.css b/src/interface/web/app/components/navMenu/navMenu.module.css new file mode 100644 index 00000000..fa28d358 --- /dev/null +++ b/src/interface/web/app/components/navMenu/navMenu.module.css @@ -0,0 +1,96 @@ +menu.menu a { + color: var(--main-text-color); + text-decoration: none; + font-size: medium; + font-weight: normal; + padding: 0 4px; + border-radius: 4px; + display: flex; + justify-self: center; + margin: 0; + align-items: center; + 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; +} + +div.titleBar { + display: grid; + grid-template-columns: 1fr auto; + padding: 16px 0; + margin: auto; +} + +div.titleBar menu { + padding: 0; + margin: 0; + border-radius: 0.5rem; + display: grid; + grid-auto-flow: column; + gap: 32px; +} + +div.settingsMenuProfile img { + border-radius: 50%; + width: 32px; + height: 32px; + margin: 0; +} + +div.settingsMenu { + color: var(--main-text-color); + padding: 0 4px; + border-radius: 4px; + display: flex; + justify-self: center; + margin: 0; + align-items: center; +} + +div.settingsMenu:hover { + background-color: var(--primary-hover); + cursor: pointer; +} + +div.settingsMenuOptions { + display: block; + grid-auto-flow: row; + position: absolute; + background-color: var(--background-color); + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + top: 64px; + text-align: left; + padding: 8px; + border-radius: 8px; +} + +div.settingsMenuOptions a { + padding: 4px; +} + +div.settingsMenuUsername { + font-weight: bold; +} + +@media screen and (max-width: 600px) { + menu.menu span { + display: none; + } + + div.settingsMenuOptions { + right: 4px; + } +} diff --git a/src/interface/web/app/components/navMenu/navMenu.tsx b/src/interface/web/app/components/navMenu/navMenu.tsx new file mode 100644 index 00000000..7d2522d3 --- /dev/null +++ b/src/interface/web/app/components/navMenu/navMenu.tsx @@ -0,0 +1,106 @@ +'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'; + + +interface NavMenuProps { + selected: string; +} + +function SettingsMenu(props: UserProfile) { + const [showSettings, setShowSettings] = useState(false); + + return ( +
+
setShowSettings(!showSettings)}> + {props.username} +
+ {showSettings && ( +
+
{props.username}
+ + Settings + + + Github + + + Help + + + Logout + +
+ )} +
+ ); +} +export default function NavMenu(props: NavMenuProps) { + + let userData = useAuthenticatedData(); + return ( + + ) +} diff --git a/src/interface/web/app/components/referencePanel/referencePanel.module.css b/src/interface/web/app/components/referencePanel/referencePanel.module.css new file mode 100644 index 00000000..54ae350d --- /dev/null +++ b/src/interface/web/app/components/referencePanel/referencePanel.module.css @@ -0,0 +1,32 @@ +div.panel { + padding: 1rem; + border-radius: 1rem; + background-color: var(--calm-blue); + color: var(--main-text-color); + 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; +} diff --git a/src/interface/web/app/components/referencePanel/referencePanel.tsx b/src/interface/web/app/components/referencePanel/referencePanel.tsx new file mode 100644 index 00000000..93ea439d --- /dev/null +++ b/src/interface/web/app/components/referencePanel/referencePanel.tsx @@ -0,0 +1,193 @@ +'use client' + +import styles from "./referencePanel.module.css"; + +import { useState } from "react"; + +import markdownIt from "markdown-it"; +const md = new markdownIt({ + html: true, + linkify: true, + typographer: true +}); + +import { SingleChatMessage, Context, WebPage, OnlineContextData } from "../chatMessage/chatMessage"; + +interface ReferencePanelProps { + referencePanelData: SingleChatMessage | null; + setShowReferencePanel: (showReferencePanel: boolean) => void; +} + +export function hasValidReferences(referencePanelData: SingleChatMessage | null) { + 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)) + ) + ); +} + +function CompiledReference(props: { context: (Context | string) }) { + + 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; + } + + const [showSnippet, setShowSnippet] = useState(false); + + return ( +
+
setShowSnippet(!showSnippet)}> +
+ {file} +
+
+
+ {snippet} +
+
+
+
+ ) +} + +function WebPageReference(props: { webpages: WebPage, query: string | null }) { + + let snippet = md.render(props.webpages.snippet); + + const [showSnippet, setShowSnippet] = useState(false); + + return ( + + ) +} + +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 ( +
+ { + webpages && ( + !Array.isArray(webpages) ? ( + + ) : ( + webpages.map((webpage, index) => { + return + }) + ) + ) + } + { + answerBox && ( +
+
+ {answerBox.title} +
+
+
+ {answerBox.answer} +
+
+
+ ) + } + { + peopleAlsoAsk && peopleAlsoAsk.map((people, index) => { + return ( +
+ +
+
+ {people.snippet} +
+
+
+ ) + }) + } + { + knowledgeGraph && ( +
+ +
+
+ {knowledgeGraph.description} +
+
+
+ ) + } +
+ + ) +} + +export default function ReferencePanel(props: ReferencePanelProps) { + + if (!props.referencePanelData) { + return null; + } + + if (!hasValidReferences(props.referencePanelData)) { + return null; + } + + return ( +
+ References + { + props.referencePanelData?.context.map((context, index) => { + return + }) + } + { + Object.entries(props.referencePanelData?.onlineContext || {}).map(([key, onlineContextData], index) => { + return + }) + } +
+ ); +} diff --git a/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx b/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx new file mode 100644 index 00000000..a6561423 --- /dev/null +++ b/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx @@ -0,0 +1,151 @@ +'use client' + +import styles from "./sidePanel.module.css"; + +import { useEffect, useState } from "react"; + +import { UserProfile } from "@/app/common/auth"; +import Link from "next/link"; + +interface ChatHistory { + conversation_id: string; + slug: string; +} + +function ChatSession(prop: ChatHistory) { + return ( +
+ +

{prop.slug || "New Conversation 🌱"}

+ +
+ ); +} + +interface ChatSessionsModalProps { + data: ChatHistory[]; + setIsExpanded: React.Dispatch>; +} + +function ChatSessionsModal({data, setIsExpanded}: ChatSessionsModalProps) { + return ( +
+
+ {data.map((chatHistory) => ( + + ))} + +
+
+ ); +} + +export default function SidePanel() { + + const [data, setData] = useState(null); + const [dataToShow, setDataToShow] = useState(null); + const [isLoading, setLoading] = useState(true) + const [enabled, setEnabled] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const [userProfile, setUserProfile] = useState(null); + + 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)); + } + }) + .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 ( +
+ { + enabled ? +
+
+
+ { userProfile && +
+ profile +

{userProfile?.username}

+
+ } +
+ +

Recent Conversations

+
+
+ {dataToShow && dataToShow.map((chatHistory) => ( + + ))} +
+ { + (data && data.length > 5) && ( + (isExpanded) ? + + : + + ) + } +
+ : +
+
+ { userProfile && +
+ profile +
+ } + +
+
+ } + +
+ ); +} diff --git a/src/interface/web/app/components/sidePanel/sidePanel.module.css b/src/interface/web/app/components/sidePanel/sidePanel.module.css new file mode 100644 index 00000000..ea675fa0 --- /dev/null +++ b/src/interface/web/app/components/sidePanel/sidePanel.module.css @@ -0,0 +1,98 @@ +div.session { + padding: 0.5rem; + margin-bottom: 0.25rem; + border-radius: 0.5rem; + color: var(--main-text-color); + cursor: pointer; + max-width: 14rem; +} + +button.button { + border: none; + outline: none; + background-color: transparent; + cursor: pointer; + color: var(--main-text-color); + width: 24px; +} + +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; + padding: 1rem; + border-radius: 1rem; + background-color: var(--calm-blue); + color: var(--main-text-color); + height: 100%; + overflow-y: auto; + max-width: auto; +} + +div.expanded { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; +} + +div.collapsed { + display: grid; + grid-template-columns: 1fr; +} + +div.session:hover { + background-color: var(--calmer-blue); +} + +p.session { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +div.header { + display: grid; + grid-template-columns: 1fr auto; +} + +img.profile { + width: 24px; + height: 24px; + border-radius: 50%; +} + + +div.modalSessionsList { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--frosted-background-color); + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(2px); +} + +div.modalSessionsList div.content { + max-width: 80%; + max-height: 80%; + background-color: var(--frosted-background-color); + overflow: auto; + padding: 20px; + border-radius: 10px; +} + +div.modalSessionsList div.session { + max-width: 100%; + text-overflow: ellipsis; +} diff --git a/src/interface/web/app/components/suggestions/suggestionCard.tsx b/src/interface/web/app/components/suggestions/suggestionCard.tsx new file mode 100644 index 00000000..27ba7590 --- /dev/null +++ b/src/interface/web/app/components/suggestions/suggestionCard.tsx @@ -0,0 +1,32 @@ +'use client' + +import styles from "./suggestions.module.css"; + +interface SuggestionCardProps { + title: string; + body: string; + link: string; + styleClass: string; +} + +export default function SuggestionCard(data: SuggestionCardProps) { + + return ( +
+
+ {data.title} +
+
+ {data.body} +
+ +
+ ); +} diff --git a/src/interface/web/app/components/suggestions/suggestions.module.css b/src/interface/web/app/components/suggestions/suggestions.module.css new file mode 100644 index 00000000..0663f7cf --- /dev/null +++ b/src/interface/web/app/components/suggestions/suggestions.module.css @@ -0,0 +1,40 @@ +div.pink { + background-color: #f8d1f8; + color: #000000; +} + +div.blue { + background-color: #d1f8f8; + color: #000000; +} + +div.green { + background-color: #d1f8d1; + color: #000000; +} + +div.purple { + background-color: #f8d1f8; + color: #000000; +} + +div.yellow { + background-color: #f8f8d1; + color: #000000; +} + +div.card { + padding: 1rem; + margin: 1rem; + border: 1px solid #000000; + border-radius: 0.5rem; +} + +div.title { + font-size: 1.5rem; + font-weight: bold; +} + +div.body { + font-size: 1rem; +} diff --git a/src/khoj/routers/web_client.py b/src/khoj/routers/web_client.py index 1dc6a2f4..318ab1a2 100644 --- a/src/khoj/routers/web_client.py +++ b/src/khoj/routers/web_client.py @@ -148,33 +148,10 @@ def login_page(request: Request): @web_client.get("/agents", response_class=HTMLResponse) def agents_page(request: Request): - user: KhojUser = request.user.object if request.user.is_authenticated else None - user_picture = request.session.get("user", {}).get("picture") if user else None - has_documents = EntryAdapters.user_has_entries(user=user) - agents = AgentAdapters.get_all_accessible_agents(user) - agents_packet = list() - for agent in agents: - agents_packet.append( - { - "slug": agent.slug, - "avatar": agent.avatar, - "name": agent.name, - "personality": agent.personality, - "public": agent.public, - "creator": agent.creator.username if agent.creator else None, - "managed_by_admin": agent.managed_by_admin, - } - ) return templates.TemplateResponse( - "agents.html", + "agents/index.html", context={ "request": request, - "agents": agents_packet, - "khoj_version": state.khoj_version, - "username": user.username if user else None, - "has_documents": has_documents, - "is_active": has_required_scope(request, ["premium"]), - "user_photo": user_picture, }, )