Merge branch 'master' of github.com:khoj-ai/khoj into features/advanced-reasoning

This commit is contained in:
sabaimran
2024-10-23 19:15:51 -07:00
85 changed files with 5132 additions and 3202 deletions

View File

@@ -326,7 +326,7 @@
entries.forEach(entry => {
// If the element is in the viewport, fetch the remaining message and unobserve the element
if (entry.isIntersecting) {
fetchRemainingChatMessages(chatHistoryUrl, headers);
fetchRemainingChatMessages(chatHistoryUrl, headers, chatBody.dataset.conversation_id, hostURL);
observer.unobserve(entry.target);
}
});
@@ -342,7 +342,11 @@
new Date(chat_log.created),
chat_log.onlineContext,
chat_log.intent?.type,
chat_log.intent?.["inferred-queries"]);
chat_log.intent?.["inferred-queries"],
chatBody.dataset.conversationId ?? "",
hostURL,
);
chatBody.appendChild(messageElement);
// When the 4th oldest message is within viewing distance (~60% scrolled up)
@@ -421,7 +425,7 @@
}
}
function fetchRemainingChatMessages(chatHistoryUrl, headers) {
function fetchRemainingChatMessages(chatHistoryUrl, headers, conversationId, hostURL) {
// Create a new IntersectionObserver
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
@@ -435,7 +439,9 @@
new Date(chat_log.created),
chat_log.onlineContext,
chat_log.intent?.type,
chat_log.intent?.["inferred-queries"]
chat_log.intent?.["inferred-queries"],
chatBody.dataset.conversationId ?? "",
hostURL,
);
entry.target.replaceWith(messageElement);

View File

@@ -189,11 +189,19 @@ function processOnlineReferences(referenceSection, onlineContext) { //same
return numOnlineReferences;
}
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null) { //same
function renderMessageWithReference(message, by, context=null, dt=null, onlineContext=null, intentType=null, inferredQueries=null, conversationId=null, hostURL=null) {
let chatEl;
if (intentType?.includes("text-to-image")) {
let imageMarkdown = generateImageMarkdown(message, intentType, inferredQueries);
chatEl = renderMessage(imageMarkdown, by, dt, null, false, "return");
} else if (intentType === "excalidraw") {
let domain = hostURL ?? "https://app.khoj.dev/";
if (!domain.endsWith("/")) domain += "/";
let excalidrawMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in the web app at ${domain}chat?conversationId=${conversationId}`;
chatEl = renderMessage(excalidrawMessage, by, dt, null, false, "return");
} else {
chatEl = renderMessage(message, by, dt, null, false, "return");
}
@@ -312,7 +320,6 @@ function formatHTMLMessage(message, raw=false, willReplace=true) { //same
}
function createReferenceSection(references, createLinkerSection=false) {
console.log("linker data: ", createLinkerSection);
let referenceSection = document.createElement('div');
referenceSection.classList.add("reference-section");
referenceSection.classList.add("collapsed");
@@ -417,7 +424,11 @@ function handleImageResponse(imageJson, rawResponse) {
rawResponse += `![generated_image](${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image-v3") {
rawResponse = `![](data:image/webp;base64,${imageJson.image})`;
} else if (imageJson.intentType === "excalidraw") {
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in the web app`;
rawResponse += redirectMessage;
}
if (inferredQuery) {
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`;
}

View File

@@ -19,7 +19,7 @@ const textFileTypes = [
'org', 'md', 'markdown', 'txt', 'html', 'xml',
// Other valid text file extensions from https://google.github.io/magika/model/config.json
'appleplist', 'asm', 'asp', 'batch', 'c', 'cs', 'css', 'csv', 'eml', 'go', 'html', 'ini', 'internetshortcut', 'java', 'javascript', 'json', 'latex', 'lisp', 'makefile', 'markdown', 'mht', 'mum', 'pem', 'perl', 'php', 'powershell', 'python', 'rdf', 'rst', 'rtf', 'ruby', 'rust', 'scala', 'shell', 'smali', 'sql', 'svg', 'symlinktext', 'txt', 'vba', 'winregistry', 'xml', 'yaml']
const binaryFileTypes = ['pdf', 'jpg', 'jpeg', 'png']
const binaryFileTypes = ['pdf', 'jpg', 'jpeg', 'png', 'webp']
const validFileTypes = textFileTypes.concat(binaryFileTypes);
const schema = {
@@ -104,6 +104,8 @@ function filenameToMimeType (filename) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
case 'md':
case 'markdown':
return 'text/markdown';

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.25.0",
"version": "1.26.4",
"description": "Your Second Brain",
"author": "Khoj Inc. <team@khoj.dev>",
"license": "GPL-3.0-or-later",

View File

@@ -6,7 +6,7 @@
;; Saba Imran <saba@khoj.dev>
;; Description: Your Second Brain
;; Keywords: search, chat, ai, org-mode, outlines, markdown, pdf, image
;; Version: 1.25.0
;; Version: 1.26.4
;; Package-Requires: ((emacs "27.1") (transient "0.3.0") (dash "2.19.1"))
;; URL: https://github.com/khoj-ai/khoj/tree/master/src/interface/emacs
@@ -127,6 +127,11 @@
(const "image")
(const "pdf")))
(defcustom khoj-default-agent "khoj"
"The default agent to chat with. See https://app.khoj.dev/agents for available options."
:group 'khoj
:type 'string)
;; --------------------------
;; Khoj Dynamic Configuration
@@ -144,6 +149,9 @@
(defconst khoj--chat-buffer-name "*🏮 Khoj Chat*"
"Name of chat buffer for Khoj.")
(defvar khoj--selected-agent khoj-default-agent
"Currently selected Khoj agent.")
(defvar khoj--content-type "org"
"The type of content to perform search on.")
@@ -656,13 +664,15 @@ Simplified fork of `org-cycle-content' from Emacs 29.1 to work with >=27.1."
;; --------------
;; Query Khoj API
;; --------------
(defun khoj--call-api (path &optional method params callback &rest cbargs)
"Sync call API at PATH with METHOD and query PARAMS as kv assoc list.
(defun khoj--call-api (path &optional method params body callback &rest cbargs)
"Sync call API at PATH with METHOD, query PARAMS and BODY as kv assoc list.
Optionally apply CALLBACK with JSON parsed response and CBARGS."
(let* ((url-request-method (or method "GET"))
(url-request-extra-headers `(("Authorization" . ,(format "Bearer %s" khoj-api-key))))
(param-string (if params (url-build-query-string params) ""))
(query-url (format "%s%s?%s&client=emacs" khoj-server-url path param-string))
(url-request-extra-headers `(("Authorization" . ,(format "Bearer %s" khoj-api-key)) ("Content-Type" . "application/json")))
(url-request-data (if body (json-encode body) nil))
(param-string (url-build-query-string (append params '((client "emacs")))))
(query-url (format "%s%s?%s" khoj-server-url path param-string))
(cbargs (if (and (listp cbargs) (listp (car cbargs))) (car cbargs) cbargs))) ; normalize cbargs to (a b) from ((a b)) if required
(with-temp-buffer
(condition-case ex
@@ -682,8 +692,8 @@ Optionally apply CALLBACK with JSON parsed response and CBARGS."
(url-request-extra-headers `(("Authorization" . ,(format "Bearer %s" khoj-api-key)) ("Content-Type" . "application/json")))
(url-request-data (if body (json-encode body) nil))
(param-string (url-build-query-string (append params '((client "emacs")))))
(cbargs (if (and (listp cbargs) (listp (car cbargs))) (car cbargs) cbargs)) ; normalize cbargs to (a b) from ((a b)) if required
(query-url (format "%s%s?%s" khoj-server-url path param-string)))
(query-url (format "%s%s?%s" khoj-server-url path param-string))
(cbargs (if (and (listp cbargs) (listp (car cbargs))) (car cbargs) cbargs))) ; normalize cbargs to (a b) from ((a b)) if required
(url-retrieve query-url
(lambda (status)
(if (plist-get status :error)
@@ -699,7 +709,7 @@ Optionally apply CALLBACK with JSON parsed response and CBARGS."
(defun khoj--get-enabled-content-types ()
"Get content types enabled for search from API."
(khoj--call-api "/api/content/types" "GET" nil `(lambda (item) (mapcar #'intern item))))
(khoj--call-api "/api/content/types" "GET" nil nil `(lambda (item) (mapcar #'intern item))))
(defun khoj--query-search-api-and-render-results (query content-type buffer-name &optional rerank is-find-similar)
"Query Khoj Search API with QUERY, CONTENT-TYPE and RERANK as query params.
@@ -913,14 +923,16 @@ Call CALLBACK func with response and CBARGS."
(let ((selected-session-id (khoj--select-conversation-session "Open")))
(khoj--load-chat-session khoj--chat-buffer-name selected-session-id)))
(defun khoj--create-chat-session ()
"Create new chat session."
(khoj--call-api "/api/chat/sessions" "POST"))
(defun khoj--create-chat-session (&optional agent)
"Create new chat session with AGENT."
(khoj--call-api "/api/chat/sessions"
"POST"
(when agent `(("agent_slug" ,agent)))))
(defun khoj--new-conversation-session ()
"Create new Khoj conversation session."
(defun khoj--new-conversation-session (&optional agent)
"Create new Khoj conversation session with AGENT."
(thread-last
(khoj--create-chat-session)
(khoj--create-chat-session agent)
(assoc 'conversation_id)
(cdr)
(khoj--chat)))
@@ -935,6 +947,15 @@ Call CALLBACK func with response and CBARGS."
(khoj--select-conversation-session "Delete")
(khoj--delete-chat-session)))
(defun khoj--get-agents ()
"Get list of available Khoj agents."
(let* ((response (khoj--call-api "/api/agents" "GET"))
(agents (mapcar (lambda (agent)
(cons (cdr (assoc 'name agent))
(cdr (assoc 'slug agent))))
response)))
agents))
(defun khoj--render-chat-message (message sender &optional receive-date)
"Render chat messages as `org-mode' list item.
MESSAGE is the text of the chat message.
@@ -1246,6 +1267,20 @@ Paragraph only starts at first text after blank line."
;; dynamically set choices to content types enabled on khoj backend
:choices (or (ignore-errors (mapcar #'symbol-name (khoj--get-enabled-content-types))) '("all" "org" "markdown" "pdf" "image")))
(transient-define-argument khoj--agent-switch ()
:class 'transient-switches
:argument-format "--agent=%s"
:argument-regexp ".+"
:init-value (lambda (obj)
(oset obj value (format "--agent=%s" khoj--selected-agent)))
:choices (or (ignore-errors (mapcar #'cdr (khoj--get-agents))) '("khoj"))
:reader (lambda (prompt initial-input history)
(let* ((agents (khoj--get-agents))
(selected (completing-read prompt agents nil t initial-input history))
(slug (cdr (assoc selected agents))))
(setq khoj--selected-agent slug)
slug)))
(transient-define-suffix khoj--search-command (&optional args)
(interactive (list (transient-args transient-current-command)))
(progn
@@ -1287,10 +1322,11 @@ Paragraph only starts at first text after blank line."
(interactive (list (transient-args transient-current-command)))
(khoj--open-conversation-session))
(transient-define-suffix khoj--new-conversation-session-command (&optional _)
(transient-define-suffix khoj--new-conversation-session-command (&optional args)
"Command to select Khoj conversation sessions to open."
(interactive (list (transient-args transient-current-command)))
(khoj--new-conversation-session))
(let ((agent-slug (transient-arg-value "--agent=" args)))
(khoj--new-conversation-session agent-slug)))
(transient-define-suffix khoj--delete-conversation-session-command (&optional _)
"Command to select Khoj conversation sessions to delete."
@@ -1298,14 +1334,15 @@ Paragraph only starts at first text after blank line."
(khoj--delete-conversation-session))
(transient-define-prefix khoj--chat-menu ()
"Open the Khoj chat menu."
["Act"
("c" "Chat" khoj--chat-command)
("o" "Open Conversation" khoj--open-conversation-session-command)
("n" "New Conversation" khoj--new-conversation-session-command)
("d" "Delete Conversation" khoj--delete-conversation-session-command)
("q" "Quit" transient-quit-one)
])
"Create the Khoj Chat Menu and Execute Commands."
[["Configure"
("a" "Select Agent" khoj--agent-switch)]]
[["Act"
("c" "Chat" khoj--chat-command)
("o" "Open Conversation" khoj--open-conversation-session-command)
("n" "New Conversation" khoj--new-conversation-session-command)
("d" "Delete Conversation" khoj--delete-conversation-session-command)
("q" "Quit" transient-quit-one)]])
(transient-define-prefix khoj--menu ()
"Create Khoj Menu to Configure and Execute Commands."

View File

@@ -1,7 +1,7 @@
{
"id": "khoj",
"name": "Khoj",
"version": "1.25.0",
"version": "1.26.4",
"minAppVersion": "0.15.0",
"description": "Your Second Brain",
"author": "Khoj Inc.",

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.25.0",
"version": "1.26.4",
"description": "Your Second Brain",
"author": "Debanjum Singh Solanky, Saba Imran <team@khoj.dev>",
"license": "GPL-3.0-or-later",

View File

@@ -484,12 +484,13 @@ export class KhojChatView extends KhojPaneView {
dt?: Date,
intentType?: string,
inferredQueries?: string[],
conversationId?: string,
) {
if (!message) return;
let chatMessageEl;
if (intentType?.includes("text-to-image")) {
let imageMarkdown = this.generateImageMarkdown(message, intentType, inferredQueries);
if (intentType?.includes("text-to-image") || intentType === "excalidraw") {
let imageMarkdown = this.generateImageMarkdown(message, intentType, inferredQueries, conversationId);
chatMessageEl = this.renderMessage(chatEl, imageMarkdown, sender, dt);
} else {
chatMessageEl = this.renderMessage(chatEl, message, sender, dt);
@@ -509,7 +510,7 @@ export class KhojChatView extends KhojPaneView {
chatMessageBodyEl.appendChild(this.createReferenceSection(references));
}
generateImageMarkdown(message: string, intentType: string, inferredQueries?: string[]) {
generateImageMarkdown(message: string, intentType: string, inferredQueries?: string[], conversationId?: string): string {
let imageMarkdown = "";
if (intentType === "text-to-image") {
imageMarkdown = `![](data:image/png;base64,${message})`;
@@ -517,6 +518,10 @@ export class KhojChatView extends KhojPaneView {
imageMarkdown = `![](${message})`;
} else if (intentType === "text-to-image-v3") {
imageMarkdown = `![](data:image/webp;base64,${message})`;
} else if (intentType === "excalidraw") {
const domain = this.setting.khojUrl.endsWith("/") ? this.setting.khojUrl : `${this.setting.khojUrl}/`;
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in ${domain}chat?conversationId=${conversationId}`;
imageMarkdown = redirectMessage;
}
if (inferredQueries) {
imageMarkdown += "\n\n**Inferred Query**:";
@@ -884,6 +889,7 @@ export class KhojChatView extends KhojPaneView {
new Date(chatLog.created),
chatLog.intent?.type,
chatLog.intent?.["inferred-queries"],
chatBodyEl.dataset.conversationId ?? "",
);
// push the user messages to the chat history
if(chatLog.by === "you"){
@@ -1354,6 +1360,10 @@ export class KhojChatView extends KhojPaneView {
rawResponse += `![generated_image](${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image-v3") {
rawResponse = `![](data:image/webp;base64,${imageJson.image})`;
} else if (imageJson.intentType === "excalidraw") {
const domain = this.setting.khojUrl.endsWith("/") ? this.setting.khojUrl : `${this.setting.khojUrl}/`;
const redirectMessage = `Hey, I'm not ready to show you diagrams yet here. But you can view it in ${domain}`;
rawResponse += redirectMessage;
}
if (inferredQuery) {
rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`;

View File

@@ -37,6 +37,8 @@ function filenameToMimeType (filename: TFile): string {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
case 'md':
case 'markdown':
return 'text/markdown';
@@ -50,7 +52,7 @@ function filenameToMimeType (filename: TFile): string {
export const fileTypeToExtension = {
'pdf': ['pdf'],
'image': ['png', 'jpg', 'jpeg'],
'image': ['png', 'jpg', 'jpeg', 'webp'],
'markdown': ['md', 'markdown'],
};
export const supportedImageFilesTypes = fileTypeToExtension.image;

View File

@@ -77,5 +77,10 @@
"1.23.3": "0.15.0",
"1.24.0": "0.15.0",
"1.24.1": "0.15.0",
"1.25.0": "0.15.0"
"1.25.0": "0.15.0",
"1.26.0": "0.15.0",
"1.26.1": "0.15.0",
"1.26.2": "0.15.0",
"1.26.3": "0.15.0",
"1.26.4": "0.15.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -79,7 +79,7 @@ div.titleBar {
div.chatBoxBody {
display: grid;
height: 100%;
width: 70%;
width: 95%;
margin: auto;
}

View File

@@ -47,7 +47,14 @@ export default function RootLayout({
child-src 'none';
object-src 'none';"
></meta>
<body className={inter.className}>{children}</body>
<body className={inter.className}>
{children}
<script
dangerouslySetInnerHTML={{
__html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`,
}}
/>
</body>
</html>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import styles from "./chat.module.css";
import React, { Suspense, useEffect, useState } from "react";
import React, { Suspense, useEffect, useRef, useState } from "react";
import SidePanel, { ChatSessionActionMenu } from "../components/sidePanel/chatHistorySidePanel";
import ChatHistory from "../components/chatHistory/chatHistory";
@@ -19,11 +19,9 @@ import {
StreamMessage,
} from "../components/chatMessage/chatMessage";
import { useIPLocationData, useIsMobileWidth, welcomeConsole } from "../common/utils";
import ChatInputArea, { ChatOptions } from "../components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "../components/chatInputArea/chatInputArea";
import { useAuthenticatedData } from "../common/auth";
import { AgentData } from "../agents/page";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
@@ -34,32 +32,38 @@ interface ChatBodyDataProps {
setUploadedFiles: (files: string[]) => void;
isMobileWidth?: boolean;
isLoggedIn: boolean;
setImage64: (image64: string) => void;
setImages: (images: string[]) => void;
}
function ChatBodyData(props: ChatBodyDataProps) {
const searchParams = useSearchParams();
const conversationId = searchParams.get("conversationId");
const [message, setMessage] = useState("");
const [image, setImage] = useState<string | null>(null);
const [images, setImages] = useState<string[]>([]);
const [processingMessage, setProcessingMessage] = useState(false);
const [agentMetadata, setAgentMetadata] = useState<AgentData | null>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const setQueryToProcess = props.setQueryToProcess;
const onConversationIdChange = props.onConversationIdChange;
useEffect(() => {
if (image) {
props.setImage64(encodeURIComponent(image));
}
}, [image, props.setImage64]);
const chatHistoryCustomClassName = props.isMobileWidth ? "w-full" : "w-4/6";
useEffect(() => {
const storedImage = localStorage.getItem("image");
if (storedImage) {
setImage(storedImage);
props.setImage64(encodeURIComponent(storedImage));
localStorage.removeItem("image");
if (images.length > 0) {
const encodedImages = images.map((image) => encodeURIComponent(image));
props.setImages(encodedImages);
}
}, [images, props.setImages]);
useEffect(() => {
const storedImages = localStorage.getItem("images");
if (storedImages) {
const parsedImages: string[] = JSON.parse(storedImages);
setImages(parsedImages);
const encodedImages = parsedImages.map((img: string) => encodeURIComponent(img));
props.setImages(encodedImages);
localStorage.removeItem("images");
}
const storedMessage = localStorage.getItem("message");
@@ -67,7 +71,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
setProcessingMessage(true);
setQueryToProcess(storedMessage);
}
}, [setQueryToProcess]);
}, [setQueryToProcess, props.setImages]);
useEffect(() => {
if (message) {
@@ -89,6 +93,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
props.streamedMessages[props.streamedMessages.length - 1].completed
) {
setProcessingMessage(false);
setImages([]); // Reset images after processing
} else {
setMessage("");
}
@@ -108,21 +113,23 @@ function ChatBodyData(props: ChatBodyDataProps) {
setAgent={setAgentMetadata}
pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages}
customClassName={chatHistoryCustomClassName}
/>
</div>
<div
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-t-2xl rounded-b-none md:rounded-xl h-fit`}
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-t-2xl rounded-b-none md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto`}
>
<ChatInputArea
agentColor={agentMetadata?.color}
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendImage={(image) => setImage(image)}
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={conversationId}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
</>
@@ -139,7 +146,7 @@ export default function Chat() {
const [queryToProcess, setQueryToProcess] = useState<string>("");
const [processQuerySignal, setProcessQuerySignal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [image64, setImage64] = useState<string>("");
const [images, setImages] = useState<string[]>([]);
const locationData = useIPLocationData() || {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -176,7 +183,7 @@ export default function Chat() {
completed: false,
timestamp: new Date().toISOString(),
rawQuery: queryToProcess || "",
uploadedImageData: decodeURIComponent(image64),
images: images,
};
setMessages((prevMessages) => [...prevMessages, newStreamMessage]);
setProcessQuerySignal(true);
@@ -208,7 +215,7 @@ export default function Chat() {
if (done) {
setQueryToProcess("");
setProcessQuerySignal(false);
setImage64("");
setImages([]);
break;
}
@@ -257,7 +264,7 @@ export default function Chat() {
country_code: locationData.countryCode,
timezone: locationData.timezone,
}),
...(image64 && { image: image64 }),
...(images.length > 0 && { images: images }),
};
const response = await fetch(chatAPI, {
@@ -271,7 +278,8 @@ export default function Chat() {
try {
await readChatStream(response);
} catch (err) {
console.error(err);
const apiError = await response.json();
console.error(apiError);
// Retrieve latest message being processed
const currentMessage = messages.find((message) => !message.completed);
if (!currentMessage) return;
@@ -280,7 +288,11 @@ export default function Chat() {
const errorMessage = (err as Error).message;
if (errorMessage.includes("Error in input stream"))
currentMessage.rawResponse = `Woops! The connection broke while I was writing my thoughts down. Maybe try again in a bit or dislike this message if the issue persists?`;
else
else if (response.status === 429) {
"detail" in apiError
? (currentMessage.rawResponse = `${apiError.detail}`)
: (currentMessage.rawResponse = `I'm a bit overwhelmed at the moment. Could you try again in a bit or dislike this message if the issue persists?`);
} else
currentMessage.rawResponse = `Umm, not sure what just happened. I see this error message: ${errorMessage}. Could you try again or dislike this message if the issue persists?`;
// Complete message streaming teardown properly
@@ -339,7 +351,7 @@ export default function Chat() {
setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth}
onConversationIdChange={handleConversationIdChange}
setImage64={setImage64}
setImages={setImages}
/>
</Suspense>
</div>

View File

@@ -68,7 +68,8 @@ export interface UserConfig {
selected_voice_model_config: number;
// user billing info
subscription_state: SubscriptionStates;
subscription_renewal_date: string;
subscription_renewal_date: string | undefined;
subscription_enabled_trial_at: string | undefined;
// server settings
khoj_cloud_subscription_url: string | undefined;
billing_enabled: boolean;
@@ -78,6 +79,7 @@ export interface UserConfig {
anonymous_mode: boolean;
notion_oauth_url: string;
detail: string;
length_of_free_trial: number;
}
export function useUserConfig(detailed: boolean = false) {
@@ -93,3 +95,15 @@ export function useUserConfig(detailed: boolean = false) {
return { userConfig, isLoadingUserConfig };
}
export function isUserSubscribed(userConfig: UserConfig | null): boolean {
return (
(userConfig?.subscription_state &&
[
SubscriptionStates.SUBSCRIBED.valueOf(),
SubscriptionStates.TRIAL.valueOf(),
SubscriptionStates.UNSUBSCRIBED.valueOf(),
].includes(userConfig.subscription_state)) ||
false
);
}

View File

@@ -11,11 +11,10 @@ export interface RawReferenceData {
codeContext?: CodeContext;
}
export interface ResponseWithReferences {
context?: Context[];
online?: OnlineContext;
codeContext?: CodeContext;
response?: string;
export interface ResponseWithIntent {
intentType: string;
response: string;
inferredQueries?: string[];
}
interface MessageChunk {
@@ -56,10 +55,14 @@ export function convertMessageChunkToJson(chunk: string): MessageChunk {
function handleJsonResponse(chunkData: any) {
const jsonData = chunkData as any;
if (jsonData.image || jsonData.detail) {
let responseWithReference = handleImageResponse(chunkData, true);
if (responseWithReference.response) return responseWithReference.response;
let responseWithIntent = handleImageResponse(chunkData, true);
return responseWithIntent;
} else if (jsonData.response) {
return jsonData.response;
return {
response: jsonData.response,
intentType: "",
inferredQueries: [],
};
} else {
throw new Error("Invalid JSON response");
}
@@ -89,8 +92,18 @@ export function processMessageChunk(
return { context, onlineContext, codeContext };
} else if (chunk.type === "message") {
const chunkData = chunk.data;
// Here, handle if the response is a JSON response with an image, but the intentType is excalidraw
if (chunkData !== null && typeof chunkData === "object") {
currentMessage.rawResponse += handleJsonResponse(chunkData);
let responseWithIntent = handleJsonResponse(chunkData);
if (responseWithIntent.intentType && responseWithIntent.intentType === "excalidraw") {
currentMessage.rawResponse = responseWithIntent.response;
} else {
currentMessage.rawResponse += responseWithIntent.response;
}
currentMessage.intentType = responseWithIntent.intentType;
currentMessage.inferredQueries = responseWithIntent.inferredQueries;
} else if (
typeof chunkData === "string" &&
chunkData.trim()?.startsWith("{") &&
@@ -98,7 +111,10 @@ export function processMessageChunk(
) {
try {
const jsonData = JSON.parse(chunkData.trim());
currentMessage.rawResponse += handleJsonResponse(jsonData);
let responseWithIntent = handleJsonResponse(jsonData);
currentMessage.rawResponse += responseWithIntent.response;
currentMessage.intentType = responseWithIntent.intentType;
currentMessage.inferredQueries = responseWithIntent.inferredQueries;
} catch (e) {
currentMessage.rawResponse += JSON.stringify(chunkData);
}
@@ -148,42 +164,26 @@ export function processMessageChunk(
return { context, onlineContext, codeContext };
}
export function handleImageResponse(imageJson: any, liveStream: boolean): ResponseWithReferences {
export function handleImageResponse(imageJson: any, liveStream: boolean): ResponseWithIntent {
let rawResponse = "";
if (imageJson.image) {
const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image";
// If response has image field, response is a generated image.
if (imageJson.intentType === "text-to-image") {
rawResponse += `![generated_image](data:image/png;base64,${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image2") {
rawResponse += `![generated_image](${imageJson.image})`;
} else if (imageJson.intentType === "text-to-image-v3") {
rawResponse = `![](data:image/webp;base64,${imageJson.image})`;
}
if (inferredQuery && !liveStream) {
rawResponse += `\n\n${inferredQuery}`;
}
// If response has image field, response may be a generated image
rawResponse = imageJson.image;
}
let reference: ResponseWithReferences = {};
let responseWithIntent: ResponseWithIntent = {
intentType: imageJson.intentType,
response: rawResponse,
inferredQueries: imageJson.inferredQueries,
};
if (imageJson.context && imageJson.context.length > 0) {
const rawReferenceAsJson = imageJson.context;
if (rawReferenceAsJson instanceof Array) {
reference.context = rawReferenceAsJson;
} else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) {
reference.online = rawReferenceAsJson;
}
}
if (imageJson.detail) {
// The detail field contains the improved image prompt
rawResponse += imageJson.detail;
}
reference.response = rawResponse;
return reference;
return responseWithIntent;
}
export function renderCodeGenImageInline(message: string, codeContext: CodeContext) {
@@ -228,7 +228,11 @@ export function modifyFileFilterForConversation(
},
body: JSON.stringify(body),
})
.then((response) => response.json())
.then((res) => {
if (!res.ok)
throw new Error(`Failed to call API at ${addUrl} with error ${res.statusText}`);
return res.json();
})
.then((data) => {
setAddedFiles(data);
})

View File

@@ -48,6 +48,7 @@ import {
Oven,
Gavel,
Broadcast,
KeyReturn,
} from "@phosphor-icons/react";
import { Markdown, OrgMode, Pdf, Word } from "@/app/components/logo/fileLogo";
@@ -193,6 +194,10 @@ export function getIconForSlashCommand(command: string, customClassName: string
}
if (command.includes("default")) {
return <KeyReturn className={className} />;
}
if (command.includes("diagram")) {
return <Shapes className={className} />;
}
@@ -241,6 +246,7 @@ function getIconFromFilename(
case "jpg":
case "jpeg":
case "png":
case "webp":
return <Image className={className} weight="fill" />;
default:
return <File className={className} weight="fill" />;

View File

@@ -70,3 +70,19 @@ export function useIsMobileWidth() {
return isMobileWidth;
}
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,20 @@
.agentPersonality p {
white-space: inherit;
overflow: hidden;
height: 77px;
line-height: 1.5;
}
div.agentPersonality {
text-align: left;
grid-column: span 3;
overflow: hidden;
}
button.infoButton {
border: none;
background-color: transparent !important;
text-align: left;
font-family: inherit;
font-size: medium;
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,7 @@ div.chatHistory {
display: flex;
flex-direction: column;
height: 100%;
}
div.chatLayout {
height: 80vh;
overflow-y: auto;
margin: 0 auto;
margin: auto;
}
div.agentIndicator a {

View File

@@ -37,6 +37,7 @@ interface ChatHistoryProps {
pendingMessage?: string;
publicConversationSlug?: string;
setAgent: (agent: AgentData) => void;
customClassName?: string;
}
function constructTrainOfThought(
@@ -255,7 +256,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
return (
<ScrollArea className={`h-[80vh] relative`} ref={scrollAreaRef}>
<div>
<div className={styles.chatHistory}>
<div className={`${styles.chatHistory} ${props.customClassName}`}>
<div ref={sentinelRef} style={{ height: "1px" }}>
{fetchingData && (
<InlineLoading message="Loading Conversation" className="opacity-50" />
@@ -299,7 +300,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
created: message.timestamp,
by: "you",
automationId: "",
uploadedImageData: message.uploadedImageData,
images: message.images,
}}
customClassName="fullHistory"
borderLeftColor={`${data?.agent?.color}-500`}
@@ -324,6 +325,12 @@ export default function ChatHistory(props: ChatHistoryProps) {
by: "khoj",
automationId: "",
rawQuery: message.rawQuery,
intent: {
type: message.intentType || "",
query: message.rawQuery,
"memory-type": "",
"inferred-queries": message.inferredQueries || [],
},
}}
customClassName="fullHistory"
borderLeftColor={`${data?.agent?.color}-500`}
@@ -344,7 +351,6 @@ export default function ChatHistory(props: ChatHistoryProps) {
created: new Date().getTime().toString(),
by: "you",
automationId: "",
uploadedImageData: props.pendingMessage,
}}
customClassName="fullHistory"
borderLeftColor={`${data?.agent?.color}-500`}
@@ -369,18 +375,20 @@ export default function ChatHistory(props: ChatHistoryProps) {
</div>
)}
</div>
{!isNearBottom && (
<button
title="Scroll to bottom"
className="absolute bottom-4 right-5 bg-white dark:bg-[hsl(var(--background))] text-neutral-500 dark:text-white p-2 rounded-full shadow-xl"
onClick={() => {
scrollToBottom();
setIsNearBottom(true);
}}
>
<ArrowDown size={24} />
</button>
)}
<div className={`${props.customClassName} fixed bottom-[15%] z-10`}>
{!isNearBottom && (
<button
title="Scroll to bottom"
className="absolute bottom-0 right-0 bg-white dark:bg-[hsl(var(--background))] text-neutral-500 dark:text-white p-2 rounded-full shadow-xl"
onClick={() => {
scrollToBottom();
setIsNearBottom(true);
}}
>
<ArrowDown size={24} />
</button>
)}
</div>
</div>
</ScrollArea>
);

View File

@@ -1,25 +1,9 @@
import styles from "./chatInputArea.module.css";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, forwardRef } from "react";
import DOMPurify from "dompurify";
import "katex/dist/katex.min.css";
import {
ArrowRight,
ArrowUp,
Browser,
ChatsTeardrop,
GlobeSimple,
Gps,
Image,
Microphone,
Notebook,
Paperclip,
X,
Question,
Robot,
Shapes,
Stop,
} from "@phosphor-icons/react";
import { ArrowUp, Microphone, Paperclip, X, Stop } from "@phosphor-icons/react";
import {
Command,
@@ -68,7 +52,7 @@ interface ChatInputProps {
agentColor?: string;
}
export default function ChatInputArea(props: ChatInputProps) {
export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((props, ref) => {
const [message, setMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -78,15 +62,17 @@ export default function ChatInputArea(props: ChatInputProps) {
const [loginRedirectMessage, setLoginRedirectMessage] = useState<string | null>(null);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [recording, setRecording] = useState(false);
const [imageUploaded, setImageUploaded] = useState(false);
const [imagePath, setImagePath] = useState<string>("");
const [imageData, setImageData] = useState<string | null>(null);
const [imagePaths, setImagePaths] = useState<string[]>([]);
const [imageData, setImageData] = useState<string[]>([]);
const [recording, setRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [progressValue, setProgressValue] = useState(0);
const [isDragAndDropping, setIsDragAndDropping] = useState(false);
const chatInputRef = ref as React.MutableRefObject<HTMLTextAreaElement>;
useEffect(() => {
if (!uploading) {
setProgressValue(0);
@@ -106,27 +92,31 @@ export default function ChatInputArea(props: ChatInputProps) {
useEffect(() => {
async function fetchImageData() {
if (imagePath) {
const response = await fetch(imagePath);
const blob = await response.blob();
const reader = new FileReader();
reader.onload = function () {
const base64data = reader.result;
setImageData(base64data as string);
};
reader.readAsDataURL(blob);
if (imagePaths.length > 0) {
const newImageData = await Promise.all(
imagePaths.map(async (path) => {
const response = await fetch(path);
const blob = await response.blob();
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}),
);
setImageData(newImageData);
}
setUploading(false);
}
setUploading(true);
fetchImageData();
}, [imagePath]);
}, [imagePaths]);
function onSendMessage() {
if (imageUploaded) {
setImageUploaded(false);
setImagePath("");
props.sendImage(imageData || "");
setImagePaths([]);
imageData.forEach((data) => props.sendImage(data));
}
if (!message.trim()) return;
@@ -168,22 +158,29 @@ export default function ChatInputArea(props: ChatInputProps) {
function uploadFiles(files: FileList) {
if (!props.isLoggedIn) {
setLoginRedirectMessage("Whoa! You need to login to upload files");
setLoginRedirectMessage("Please login to chat with your files");
setShowLoginPrompt(true);
return;
}
// check for image file
const image_endings = ["jpg", "jpeg", "png"];
// check for image files
const image_endings = ["jpg", "jpeg", "png", "webp"];
const newImagePaths: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const file_extension = file.name.split(".").pop();
if (image_endings.includes(file_extension || "")) {
setImageUploaded(true);
setImagePath(DOMPurify.sanitize(URL.createObjectURL(file)));
return;
newImagePaths.push(DOMPurify.sanitize(URL.createObjectURL(file)));
}
}
if (newImagePaths.length > 0) {
setImageUploaded(true);
setImagePaths((prevPaths) => [...prevPaths, ...newImagePaths]);
// Set focus to the input for user message after uploading files
chatInputRef?.current?.focus();
return;
}
uploadDataForIndexing(
files,
setWarning,
@@ -192,6 +189,9 @@ export default function ChatInputArea(props: ChatInputProps) {
props.setUploadedFiles,
props.conversationId,
);
// Set focus to the input for user message after uploading files
chatInputRef?.current?.focus();
}
// Assuming this function is added within the same context as the provided excerpt
@@ -270,9 +270,8 @@ export default function ChatInputArea(props: ChatInputProps) {
}
}, [recording, mediaRecorder]);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!chatInputRef.current) return;
if (!chatInputRef?.current) return;
chatInputRef.current.style.height = "auto";
chatInputRef.current.style.height =
Math.max(chatInputRef.current.scrollHeight - 24, 64) + "px";
@@ -288,9 +287,12 @@ export default function ChatInputArea(props: ChatInputProps) {
setIsDragAndDropping(false);
}
function removeImageUpload() {
setImageUploaded(false);
setImagePath("");
function removeImageUpload(index: number) {
setImagePaths((prevPaths) => prevPaths.filter((_, i) => i !== index));
setImageData((prevData) => prevData.filter((_, i) => i !== index));
if (imagePaths.length === 1) {
setImageUploaded(false);
}
}
return (
@@ -407,24 +409,11 @@ export default function ChatInputArea(props: ChatInputProps) {
</div>
)}
<div
className={`${styles.actualInputArea} items-center justify-between dark:bg-neutral-700 relative ${isDragAndDropping && "animate-pulse"}`}
className={`${styles.actualInputArea} justify-between dark:bg-neutral-700 relative ${isDragAndDropping && "animate-pulse"}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDragAndDropFiles}
>
{imageUploaded && (
<div className="absolute bottom-[80px] left-0 right-0 dark:bg-neutral-700 bg-white pt-5 pb-5 w-full rounded-lg border dark:border-none grid grid-cols-2">
<div className="pl-4 pr-4">
<img src={imagePath} alt="img" className="w-auto max-h-[100px]" />
</div>
<div className="pl-4 pr-4">
<X
className="w-6 h-6 float-right dark:hover:bg-[hsl(var(--background))] hover:bg-neutral-100 rounded-sm"
onClick={removeImageUpload}
/>
</div>
</div>
)}
<input
type="file"
multiple={true}
@@ -432,15 +421,37 @@ export default function ChatInputArea(props: ChatInputProps) {
onChange={handleFileChange}
style={{ display: "none" }}
/>
<Button
variant={"ghost"}
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}
onClick={handleFileButtonClick}
>
<Paperclip className="w-8 h-8" />
</Button>
<div className="grid w-full gap-1.5 relative">
<div className="flex items-end pb-4">
<Button
variant={"ghost"}
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}
onClick={handleFileButtonClick}
>
<Paperclip className="w-8 h-8" />
</Button>
</div>
<div className="flex-grow flex flex-col w-full gap-1.5 relative pb-2">
<div className="flex items-center gap-2 overflow-x-auto">
{imageUploaded &&
imagePaths.map((path, index) => (
<div key={index} className="relative flex-shrink-0 pb-3 pt-2 group">
<img
src={path}
alt={`img-${index}`}
className="w-auto h-16 object-cover rounded-xl"
/>
<Button
variant="ghost"
size="icon"
className="absolute -top-0 -right-2 h-5 w-5 rounded-full bg-neutral-200 dark:bg-neutral-600 hover:bg-neutral-300 dark:hover:bg-neutral-500 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeImageUpload(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
<Textarea
ref={chatInputRef}
className={`border-none w-full h-16 min-h-16 max-h-[128px] md:py-4 rounded-lg resize-none dark:bg-neutral-700 ${props.isMobileWidth ? "text-md" : "text-lg"}`}
@@ -449,9 +460,9 @@ export default function ChatInputArea(props: ChatInputProps) {
autoFocus={true}
value={message}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey && !props.isMobileWidth) {
setImageUploaded(false);
setImagePath("");
setImagePaths([]);
e.preventDefault();
onSendMessage();
}
@@ -460,58 +471,62 @@ export default function ChatInputArea(props: ChatInputProps) {
disabled={props.sendDisabled || recording}
/>
</div>
{recording ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={`${!recording && "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={() => {
setRecording(!recording);
}}
disabled={props.sendDisabled}
>
<Stop weight="fill" className="w-6 h-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
Click to stop recording and transcribe your voice.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : mediaRecorder ? (
<InlineLoading />
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={`${!message || recording || "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={() => {
setMessage("Listening...");
setRecording(!recording);
}}
disabled={props.sendDisabled}
>
<Microphone weight="fill" className="w-6 h-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
Click to transcribe your message with voice.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
className={`${(!message || recording) && "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={onSendMessage}
disabled={props.sendDisabled}
>
<ArrowUp className="w-6 h-6" weight="bold" />
</Button>
<div className="flex items-end pb-4">
{recording ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={`${!recording && "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={() => {
setRecording(!recording);
}}
disabled={props.sendDisabled}
>
<Stop weight="fill" className="w-6 h-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
Click to stop recording and transcribe your voice.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : mediaRecorder ? (
<InlineLoading />
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={`${!message || recording || "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={() => {
setMessage("Listening...");
setRecording(!recording);
}}
disabled={props.sendDisabled}
>
<Microphone weight="fill" className="w-6 h-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
Click to transcribe your message with voice.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
className={`${(!message || recording) && "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
onClick={onSendMessage}
disabled={props.sendDisabled}
>
<ArrowUp className="w-6 h-6" weight="bold" />
</Button>
</div>
</div>
</>
);
}
});
ChatInputArea.displayName = "ChatInputArea";

View File

@@ -57,7 +57,26 @@ div.emptyChatMessage {
display: none;
}
div.chatMessageContainer img {
div.imagesContainer {
display: flex;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 8px;
}
div.imageWrapper {
flex: 0 0 auto;
margin-right: 8px;
}
div.imageWrapper img {
width: auto;
height: 128px;
object-fit: cover;
border-radius: 8px;
}
div.chatMessageContainer > img {
width: auto;
height: auto;
max-width: 100%;

View File

@@ -28,6 +28,7 @@ import {
ClipboardText,
Check,
Code,
Shapes,
} from "@phosphor-icons/react";
import DOMPurify from "dompurify";
@@ -37,6 +38,7 @@ import { AgentData } from "@/app/agents/page";
import renderMathInElement from "katex/contrib/auto-render";
import "katex/dist/katex.min.css";
import ExcalidrawComponent from "../excalidraw/excalidraw";
const md = new markdownIt({
html: true,
@@ -137,7 +139,7 @@ export interface SingleChatMessage {
rawQuery?: string;
intent?: Intent;
agent?: AgentData;
uploadedImageData?: string;
images?: string[];
}
export interface StreamMessage {
@@ -150,7 +152,9 @@ export interface StreamMessage {
rawQuery: string;
timestamp: string;
agent?: AgentData;
uploadedImageData?: string;
images?: string[];
intentType?: string;
inferredQueries?: string[];
}
export interface ChatHistoryData {
@@ -232,7 +236,6 @@ interface ChatMessageProps {
borderLeftColor?: string;
isLastMessage?: boolean;
agent?: AgentData;
uploadedImageData?: string;
}
interface TrainOfThoughtProps {
@@ -276,6 +279,10 @@ function chooseIconFromHeader(header: string, iconColor: string) {
return <Aperture className={`${classNames}`} />;
}
if (compareHeader.includes("diagram")) {
return <Shapes className={`${classNames}`} />;
}
if (compareHeader.includes("paint")) {
return <Palette className={`${classNames}`} />;
}
@@ -311,6 +318,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
const [markdownRendered, setMarkdownRendered] = useState<string>("");
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [interrupted, setInterrupted] = useState<boolean>(false);
const [excalidrawData, setExcalidrawData] = useState<string>("");
const interruptedRef = useRef<boolean>(false);
const messageRef = useRef<HTMLDivElement>(null);
@@ -347,8 +355,14 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
}, [messageRef.current]);
useEffect(() => {
// Prepare initial message for rendering
let message = props.chatMessage.message;
if (props.chatMessage.intent && props.chatMessage.intent.type == "excalidraw") {
message = props.chatMessage.intent["inferred-queries"][0];
setExcalidrawData(props.chatMessage.message);
}
// Replace LaTeX delimiters with placeholders
message = message
.replace(/\\\(/g, "LEFTPAREN")
@@ -356,8 +370,50 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
.replace(/\\\[/g, "LEFTBRACKET")
.replace(/\\\]/g, "RIGHTBRACKET");
if (props.chatMessage.uploadedImageData) {
message = `![uploaded image](${props.chatMessage.uploadedImageData})\n\n${message}`;
const intentTypeHandlers = {
"text-to-image": (msg: string) => `![generated image](data:image/png;base64,${msg})`,
"text-to-image2": (msg: string) => `![generated image](${msg})`,
"text-to-image-v3": (msg: string) =>
`![generated image](data:image/webp;base64,${msg})`,
excalidraw: (msg: string) => msg,
};
// Handle intent-specific rendering
if (props.chatMessage.intent) {
const { type, "inferred-queries": inferredQueries } = props.chatMessage.intent;
console.log("intent type", type);
if (type in intentTypeHandlers) {
message = intentTypeHandlers[type as keyof typeof intentTypeHandlers](message);
}
if (type.includes("text-to-image") && inferredQueries?.length > 0) {
message += `\n\n${inferredQueries[0]}`;
}
}
// Handle user attached images rendering
let messageForClipboard = message;
let messageToRender = message;
if (props.chatMessage.images && props.chatMessage.images.length > 0) {
const sanitizedImages = props.chatMessage.images.map((image) => {
const decodedImage = image.startsWith("data%3Aimage")
? decodeURIComponent(image)
: image;
return DOMPurify.sanitize(decodedImage);
});
const imagesInMd = sanitizedImages
.map((sanitizedImage, index) => {
return `![uploaded image ${index + 1}](${sanitizedImage})`;
})
.join("\n");
const imagesInHtml = sanitizedImages
.map((sanitizedImage, index) => {
return `<div class="${styles.imageWrapper}"><img src="${sanitizedImage}" alt="uploaded image ${index + 1}" /></div>`;
})
.join("");
const userImagesInHtml = `<div class="${styles.imagesContainer}">${imagesInHtml}</div>`;
messageForClipboard = `${imagesInMd}\n\n${messageForClipboard}`;
messageToRender = `${userImagesInHtml}${messageToRender}`;
}
if (props.chatMessage.intent && props.chatMessage.intent.type == "text-to-image") {
@@ -402,10 +458,11 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
});
}
setTextRendered(message);
// Set the message text
setTextRendered(messageForClipboard);
// Render the markdown
let markdownRendered = md.render(message);
let markdownRendered = md.render(messageToRender);
// Replace placeholders with LaTeX delimiters
markdownRendered = markdownRendered
@@ -416,7 +473,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
// Sanitize and set the rendered markdown
setMarkdownRendered(DOMPurify.sanitize(markdownRendered));
}, [props.chatMessage.message, props.chatMessage.intent]);
}, [props.chatMessage.message, props.chatMessage.images, props.chatMessage.intent]);
useEffect(() => {
if (copySuccess) {
@@ -607,6 +664,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
className={styles.chatMessage}
dangerouslySetInnerHTML={{ __html: markdownRendered }}
/>
{excalidrawData && <ExcalidrawComponent data={excalidrawData} />}
</div>
<div className={styles.teaserReferencesContainer}>
<TeaserReferencesSection

View File

@@ -0,0 +1,24 @@
"use client";
import dynamic from "next/dynamic";
import { Suspense } from "react";
import Loading from "../../components/loading/loading";
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(() => import("./excalidrawWrapper").then((mod) => mod.default), {
ssr: false,
});
interface ExcalidrawComponentProps {
data: any;
}
export default function ExcalidrawComponent(props: ExcalidrawComponentProps) {
return (
<Suspense fallback={<Loading />}>
<ExcalidrawWrapper data={props.data} />
</Suspense>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { ExcalidrawProps } from "@excalidraw/excalidraw/types/types";
import { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types";
import { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/types/data/transform";
const Excalidraw = dynamic<ExcalidrawProps>(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{
ssr: false,
},
);
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
import { Button } from "@/components/ui/button";
import { ArrowsInSimple, ArrowsOutSimple } from "@phosphor-icons/react";
interface ExcalidrawWrapperProps {
data: ExcalidrawElementSkeleton[];
}
export default function ExcalidrawWrapper(props: ExcalidrawWrapperProps) {
const [excalidrawElements, setExcalidrawElements] = useState<ExcalidrawElement[]>([]);
const [expanded, setExpanded] = useState<boolean>(false);
const isValidExcalidrawElement = (element: ExcalidrawElementSkeleton): boolean => {
return (
element.x !== undefined &&
element.y !== undefined &&
element.id !== undefined &&
element.type !== undefined
);
};
useEffect(() => {
if (expanded) {
onkeydown = (e) => {
if (e.key === "Escape") {
setExpanded(false);
// Trigger a resize event to make Excalidraw adjust its size
window.dispatchEvent(new Event("resize"));
}
};
} else {
onkeydown = null;
}
}, [expanded]);
useEffect(() => {
// Do some basic validation
const basicValidSkeletons: ExcalidrawElementSkeleton[] = [];
for (const element of props.data) {
if (isValidExcalidrawElement(element as ExcalidrawElementSkeleton)) {
basicValidSkeletons.push(element as ExcalidrawElementSkeleton);
}
}
const validSkeletons: ExcalidrawElementSkeleton[] = [];
for (const element of basicValidSkeletons) {
if (element.type === "frame") {
continue;
}
if (element.type === "arrow") {
const start = basicValidSkeletons.find((child) => child.id === element.start?.id);
const end = basicValidSkeletons.find((child) => child.id === element.end?.id);
if (start && end) {
validSkeletons.push(element);
}
} else {
validSkeletons.push(element);
}
}
for (const element of basicValidSkeletons) {
if (element.type === "frame") {
const children = element.children?.map((childId) => {
return validSkeletons.find((child) => child.id === childId);
});
// Get the valid children, filter out any undefined values
const validChildrenIds: readonly string[] = children
?.map((child) => child?.id)
.filter((id) => id !== undefined) as string[];
if (validChildrenIds === undefined || validChildrenIds.length === 0) {
continue;
}
validSkeletons.push({
...element,
children: validChildrenIds,
});
}
}
const elements = convertToExcalidrawElements(validSkeletons);
setExcalidrawElements(elements);
}, []);
return (
<div className="relative">
<div
className={`${expanded ? "fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-50 flex items-center justify-center" : ""}`}
>
<Button
onClick={() => {
setExpanded(!expanded);
// Trigger a resize event to make Excalidraw adjust its size
window.dispatchEvent(new Event("resize"));
}}
variant={"outline"}
className={`${expanded ? "absolute top-2 left-2 z-[60]" : ""}`}
>
{expanded ? (
<ArrowsInSimple className="h-4 w-4" />
) : (
<ArrowsOutSimple className="h-4 w-4" />
)}
</Button>
<div
className={`
${expanded ? "w-[80vw] h-[80vh]" : "w-full h-[500px]"}
bg-white overflow-hidden rounded-lg relative
`}
>
<Excalidraw
initialData={{
elements: excalidrawElements,
appState: { zenModeEnabled: true },
scrollToContent: true,
}}
// TODO - Create a common function to detect if the theme is dark?
theme={localStorage.getItem("theme") === "dark" ? "dark" : "light"}
validateEmbeddable={true}
renderTopRightUI={(isMobile, appState) => {
return <></>;
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -98,7 +98,11 @@ import { KhojLogoType } from "@/app/components/logo/khojLogo";
import NavMenu from "@/app/components/navMenu/navMenu";
// Define a fetcher function
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const fetcher = (url: string) =>
fetch(url).then((res) => {
if (!res.ok) throw new Error(`Failed to call API at ${url} with error ${res.statusText}`);
return res.json();
});
interface GroupedChatHistory {
[key: string]: ChatHistory[];
@@ -181,20 +185,15 @@ function FilesMenu(props: FilesMenuProps) {
useEffect(() => {
if (!files) return;
const uniqueFiles = Array.from(new Set(files));
let sortedUniqueFiles = Array.from(new Set(files)).sort();
// First, sort lexically
uniqueFiles.sort();
let sortedFiles = uniqueFiles;
if (addedFiles) {
sortedFiles = addedFiles.concat(
sortedFiles.filter((filename: string) => !addedFiles.includes(filename)),
if (Array.isArray(addedFiles)) {
sortedUniqueFiles = addedFiles.concat(
sortedUniqueFiles.filter((filename: string) => !addedFiles.includes(filename)),
);
}
setUnfilteredFiles(sortedFiles);
setUnfilteredFiles(sortedUniqueFiles);
}, [files, addedFiles]);
useEffect(() => {
@@ -204,8 +203,10 @@ function FilesMenu(props: FilesMenuProps) {
}, [props.uploadedFiles]);
useEffect(() => {
if (selectedFiles) {
if (Array.isArray(selectedFiles)) {
setAddedFiles(selectedFiles);
} else {
setAddedFiles([]);
}
}, [selectedFiles]);
@@ -269,7 +270,7 @@ function FilesMenu(props: FilesMenuProps) {
</CommandItem>
)}
{unfilteredFiles.map((filename: string) =>
addedFiles && addedFiles.includes(filename) ? (
Array.isArray(addedFiles) && addedFiles.includes(filename) ? (
<CommandItem
key={filename}
value={filename}

View File

@@ -3,26 +3,33 @@ import "./globals.css";
import styles from "./page.module.css";
import "katex/dist/katex.min.css";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import Image from "next/image";
import { ArrowCounterClockwise } from "@phosphor-icons/react";
import { Card, CardTitle } from "@/components/ui/card";
import SuggestionCard from "@/app/components/suggestions/suggestionCard";
import SidePanel from "@/app/components/sidePanel/chatHistorySidePanel";
import Loading from "@/app/components/loading/loading";
import ChatInputArea, { ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { Suggestion, suggestionsData } from "@/app/components/suggestions/suggestionsData";
import LoginPrompt from "@/app/components/loginPrompt/loginPrompt";
import { useAuthenticatedData, UserConfig, useUserConfig } from "@/app/common/auth";
import {
isUserSubscribed,
useAuthenticatedData,
UserConfig,
useUserConfig,
} from "@/app/common/auth";
import { convertColorToBorderClass } from "@/app/common/colorUtils";
import { getIconFromIconName } from "@/app/common/iconUtils";
import { AgentData } from "@/app/agents/page";
import { createNewConversation } from "./common/chatFunctions";
import { useIsMobileWidth } from "./common/utils";
import { useSearchParams } from "next/navigation";
import { useDebounce, useIsMobileWidth } from "./common/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { AgentCard } from "@/app/components/agentCard/agentCard";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
@@ -44,14 +51,19 @@ function FisherYatesShuffle(array: any[]) {
function ChatBodyData(props: ChatBodyDataProps) {
const [message, setMessage] = useState("");
const [image, setImage] = useState<string | null>(null);
const [images, setImages] = useState<string[]>([]);
const [processingMessage, setProcessingMessage] = useState(false);
const [greeting, setGreeting] = useState("");
const [shuffledOptions, setShuffledOptions] = useState<Suggestion[]>([]);
const [hoveredAgent, setHoveredAgent] = useState<string | null>(null);
const debouncedHoveredAgent = useDebounce(hoveredAgent, 500);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<string | null>("khoj");
const [agentIcons, setAgentIcons] = useState<JSX.Element[]>([]);
const [agents, setAgents] = useState<AgentData[]>([]);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const queryParam = searchParams.get("q");
@@ -61,6 +73,12 @@ function ChatBodyData(props: ChatBodyDataProps) {
}
}, [queryParam]);
useEffect(() => {
if (debouncedHoveredAgent) {
setIsPopoverOpen(true);
}
}, [debouncedHoveredAgent]);
const onConversationIdChange = props.onConversationIdChange;
const agentsFetcher = () =>
@@ -72,6 +90,10 @@ function ChatBodyData(props: ChatBodyDataProps) {
revalidateOnFocus: false,
});
const openAgentEditCard = (agentSlug: string) => {
router.push(`/agents?agent=${agentSlug}`);
};
function shuffleAndSetOptions() {
const shuffled = FisherYatesShuffle(suggestionsData);
setShuffledOptions(shuffled.slice(0, 3));
@@ -108,22 +130,13 @@ function ChatBodyData(props: ChatBodyDataProps) {
}, [props.chatOptionsData]);
useEffect(() => {
const nSlice = props.isMobileWidth ? 2 : 4;
const shuffledAgents = agentsData ? [...agentsData].sort(() => 0.5 - Math.random()) : [];
const agents = agentsData ? [agentsData[0]] : []; // Always add the first/default agent.
shuffledAgents.slice(0, nSlice - 1).forEach((agent) => {
if (!agents.find((a) => a.slug === agent.slug)) {
agents.push(agent);
}
});
const agents = (agentsData || []).filter((agent) => agent !== null && agent !== undefined);
setAgents(agents);
// set the first agent, which is always the default agent, as the default for chat
setSelectedAgent(agents.length > 1 ? agents[0].slug : "khoj");
//generate colored icons for the selected agents
const agentIcons = agents
.filter((agent) => agent !== null && agent !== undefined)
.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
// generate colored icons for the available agents
const agentIcons = agents.map((agent) => getIconFromIconName(agent.icon, agent.color)!);
setAgentIcons(agentIcons);
}, [agentsData, props.isMobileWidth]);
@@ -138,24 +151,39 @@ function ChatBodyData(props: ChatBodyDataProps) {
try {
const newConversationId = await createNewConversation(selectedAgent || "khoj");
onConversationIdChange?.(newConversationId);
window.location.href = `/chat?conversationId=${newConversationId}`;
localStorage.setItem("message", message);
if (image) {
localStorage.setItem("image", image);
if (images.length > 0) {
localStorage.setItem("images", JSON.stringify(images));
}
window.location.href = `/chat?conversationId=${newConversationId}`;
} catch (error) {
console.error("Error creating new conversation:", error);
setProcessingMessage(false);
}
setMessage("");
setImages([]);
}
};
processMessage();
if (message) {
if (message || images.length > 0) {
setProcessingMessage(true);
}
}, [selectedAgent, message, processingMessage, onConversationIdChange]);
// Close the agent detail hover card when scroll on agent pane
useEffect(() => {
const scrollAreaSelector = "[data-radix-scroll-area-viewport]";
const scrollAreaEl = document.querySelector<HTMLElement>(scrollAreaSelector);
const handleScroll = () => {
setHoveredAgent(null);
setIsPopoverOpen(false);
};
scrollAreaEl?.addEventListener("scroll", handleScroll);
return () => scrollAreaEl?.removeEventListener("scroll", handleScroll);
}, []);
function fillArea(link: string, type: string, prompt: string) {
if (!link) {
let message_str = "";
@@ -194,37 +222,76 @@ function ChatBodyData(props: ChatBodyDataProps) {
</h1>
</div>
{!props.isMobileWidth && (
<div className="flex pb-6 gap-2 items-center justify-center">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${
selectedAgent === agents[index].slug
? convertColorToBorderClass(agents[index].color)
: "border-stone-100 dark:border-neutral-700 text-muted-foreground"
}
hover:cursor-pointer rounded-lg px-2 py-2`}
>
<CardTitle
className="text-center text-md font-medium flex justify-center items-center"
onClick={() => setSelectedAgent(agents[index].slug)}
<ScrollArea className="w-full max-w-[600px] mx-auto">
<div className="flex pb-2 gap-2 items-center justify-center">
{agents.map((agent, index) => (
<Popover
key={`${index}-${agent.slug}`}
open={isPopoverOpen && debouncedHoveredAgent === agent.slug}
onOpenChange={(open) => {
if (!open) {
setHoveredAgent(null);
setIsPopoverOpen(false);
}
}}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
<Card
className="border-none shadow-none flex justify-center items-center hover:cursor-pointer"
onClick={() => (window.location.href = "/agents")}
>
<CardTitle className="text-center text-md font-normal flex justify-center items-center px-1.5 py-2">
See All
</CardTitle>
</Card>
</div>
<PopoverTrigger asChild>
<Card
className={`${
selectedAgent === agent.slug
? convertColorToBorderClass(agent.color)
: "border-stone-100 dark:border-neutral-700 text-muted-foreground"
}
hover:cursor-pointer rounded-lg px-2 py-2`}
onDoubleClick={() => openAgentEditCard(agent.slug)}
onClick={() => {
setSelectedAgent(agent.slug);
chatInputRef.current?.focus();
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
onMouseEnter={() => setHoveredAgent(agent.slug)}
onMouseLeave={() => {
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
>
<CardTitle className="text-center text-md font-medium flex justify-center items-center whitespace-nowrap">
{agentIcons[index]} {agent.name}
</CardTitle>
</Card>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0 border-none bg-transparent shadow-none"
onMouseLeave={() => {
setHoveredAgent(null);
setIsPopoverOpen(false);
}}
>
<AgentCard
data={agent}
userProfile={null}
isMobileWidth={props.isMobileWidth || false}
showChatButton={false}
editCard={false}
filesOptions={[]}
selectedChatModelOption=""
agentSlug=""
isSubscribed={isUserSubscribed(props.userConfig)}
setAgentChangeTriggered={() => {}}
modelOptions={[]}
inputToolOptions={{}}
outputModeOptions={{}}
/>
</PopoverContent>
</Popover>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
</div>
<div className={`mx-auto ${props.isMobileWidth ? "w-full" : "w-fit"}`}>
<div className={`mx-auto ${props.isMobileWidth ? "w-full" : "w-fit max-w-screen-md"}`}>
{!props.isMobileWidth && (
<div
className={`w-full ${styles.inputBox} shadow-lg bg-background align-middle items-center justify-center px-3 py-1 dark:bg-neutral-700 border-stone-100 dark:border-none dark:shadow-none rounded-2xl`}
@@ -232,12 +299,13 @@ function ChatBodyData(props: ChatBodyDataProps) {
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendImage={(image) => setImage(image)}
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={null}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
)}
@@ -285,40 +353,40 @@ function ChatBodyData(props: ChatBodyDataProps) {
<div
className={`${styles.inputBox} pt-1 shadow-[0_-20px_25px_-5px_rgba(0,0,0,0.1)] dark:bg-neutral-700 bg-background align-middle items-center justify-center pb-3 mx-1 rounded-t-2xl rounded-b-none`}
>
<div className="flex gap-2 items-center justify-left pt-1 pb-2 px-12">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${selectedAgent === agents[index].slug ? convertColorToBorderClass(agents[index].color) : "border-muted text-muted-foreground"} hover:cursor-pointer`}
>
<CardTitle
className="text-center text-xs font-medium flex justify-center items-center px-1.5 py-1"
onClick={() => setSelectedAgent(agents[index].slug)}
<ScrollArea className="w-full max-w-[85vw]">
<div className="flex gap-2 items-center justify-left pt-1 pb-2 px-12">
{agentIcons.map((icon, index) => (
<Card
key={`${index}-${agents[index].slug}`}
className={`${selectedAgent === agents[index].slug ? convertColorToBorderClass(agents[index].color) : "border-muted text-muted-foreground"} hover:cursor-pointer`}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
<Card
className="border-none shadow-none flex justify-center items-center hover:cursor-pointer"
onClick={() => (window.location.href = "/agents")}
>
<CardTitle
className={`text-center ${props.isMobileWidth ? "text-xs" : "text-md"} font-normal flex justify-center items-center px-1.5 py-2`}
>
See All
</CardTitle>
</Card>
</div>
<CardTitle
className="text-center text-xs font-medium flex justify-center items-center px-1.5 py-1"
onDoubleClick={() =>
openAgentEditCard(agents[index].slug)
}
onClick={() => {
setSelectedAgent(agents[index].slug);
chatInputRef.current?.focus();
}}
>
{icon} {agents[index].name}
</CardTitle>
</Card>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendImage={(image) => setImage(image)}
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={null}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
</>

View File

@@ -513,7 +513,7 @@ export default function SettingsView() {
const isMobileWidth = useIsMobileWidth();
const cardClassName =
"w-full lg:w-1/3 grid grid-flow-column border border-gray-300 shadow-md rounded-lg bg-gradient-to-b from-background to-gray-50 dark:to-gray-950";
"w-full lg:w-1/3 grid grid-flow-column border border-gray-300 shadow-md rounded-lg bg-gradient-to-b from-background to-gray-50 dark:to-gray-950 border border-opacity-50";
useEffect(() => {
setUserConfig(initialUserConfig);
@@ -640,6 +640,51 @@ export default function SettingsView() {
}
};
const enableFreeTrial = async () => {
const formatDate = (dateString: Date) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
};
try {
const response = await fetch(`/api/subscription/trial`, {
method: "POST",
});
if (!response.ok) throw new Error("Failed to enable free trial");
const responseBody = await response.json();
// Set updated user settings
if (responseBody.trial_enabled && userConfig) {
let newUserConfig = userConfig;
newUserConfig.subscription_state = SubscriptionStates.TRIAL;
const renewalDate = new Date(
Date.now() + userConfig.length_of_free_trial * 24 * 60 * 60 * 1000,
);
newUserConfig.subscription_renewal_date = formatDate(renewalDate);
newUserConfig.subscription_enabled_trial_at = new Date().toISOString();
setUserConfig(newUserConfig);
// Notify user of free trial
toast({
title: "🎉 Trial Enabled",
description: `Your free trial will end on ${newUserConfig.subscription_renewal_date}`,
});
}
} catch (error) {
console.error("Error enabling free trial:", error);
toast({
title: "⚠️ Failed to Enable Free Trial",
description:
"Failed to enable free trial. Try again or contact us at team@khoj.dev",
});
}
};
const saveName = async () => {
if (!name) return;
try {
@@ -673,7 +718,7 @@ export default function SettingsView() {
};
const updateModel = (name: string) => async (id: string) => {
if (!userConfig?.is_active && name !== "search") {
if (!userConfig?.is_active) {
toast({
title: `Model Update`,
description: `You need to be subscribed to update ${name} models`,
@@ -866,10 +911,13 @@ export default function SettingsView() {
Futurist (Trial)
</p>
<p className="text-gray-400">
You are on a 14 day trial of the Khoj
Futurist plan. Check{" "}
You are on a{" "}
{userConfig.length_of_free_trial} day trial
of the Khoj Futurist plan. Your trial ends
on {userConfig.subscription_renewal_date}.
Check{" "}
<a
href="https://khoj.dev/pricing"
href="https://khoj.dev/#pricing"
target="_blank"
>
pricing page
@@ -909,7 +957,7 @@ export default function SettingsView() {
)) ||
(userConfig.subscription_state === "expired" && (
<>
<p className="text-xl">Free Plan</p>
<p className="text-xl">Humanist</p>
{(userConfig.subscription_renewal_date && (
<p className="text-gray-400">
Subscription <b>expired</b> on{" "}
@@ -923,7 +971,7 @@ export default function SettingsView() {
<p className="text-gray-400">
Check{" "}
<a
href="https://khoj.dev/pricing"
href="https://khoj.dev/#pricing"
target="_blank"
>
pricing page
@@ -960,7 +1008,8 @@ export default function SettingsView() {
/>
Resubscribe
</Button>
)) || (
)) ||
(userConfig.subscription_enabled_trial_at && (
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
@@ -978,6 +1027,18 @@ export default function SettingsView() {
/>
Subscribe
</Button>
)) || (
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
onClick={enableFreeTrial}
>
<ArrowCircleUp
weight="bold"
className="h-5 w-5 mr-2"
/>
Enable Trial
</Button>
)}
</CardFooter>
</Card>
@@ -1172,27 +1233,6 @@ export default function SettingsView() {
</CardFooter>
</Card>
)}
{userConfig.search_model_options.length > 0 && (
<Card className={cardClassName}>
<CardHeader className="text-xl flex flex-row">
<FileMagnifyingGlass className="h-7 w-7 mr-2" />
Search
</CardHeader>
<CardContent className="overflow-hidden pb-12 grid gap-8 h-fit">
<p className="text-gray-400">
Pick the search model to find your documents
</p>
<DropdownComponent
items={userConfig.search_model_options}
selected={
userConfig.selected_search_model_config
}
callbackFunc={updateModel("search")}
/>
</CardContent>
<CardFooter className="flex flex-wrap gap-4"></CardFooter>
</Card>
)}
{userConfig.paint_model_options.length > 0 && (
<Card className={cardClassName}>
<CardHeader className="text-xl flex flex-row">

View File

@@ -27,7 +27,14 @@ export default function RootLayout({
child-src 'none';
object-src 'none';"
></meta>
<body className={inter.className}>{children}</body>
<body className={inter.className}>
{children}
<script
dangerouslySetInnerHTML={{
__html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`,
}}
/>
</body>
</html>
);
}

View File

@@ -13,7 +13,7 @@ import "katex/dist/katex.min.css";
import { useIPLocationData, useIsMobileWidth, welcomeConsole } from "../../common/utils";
import { useAuthenticatedData } from "@/app/common/auth";
import ChatInputArea, { ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { ChatInputArea, ChatOptions } from "@/app/components/chatInputArea/chatInputArea";
import { StreamMessage } from "@/app/components/chatMessage/chatMessage";
import { processMessageChunk } from "@/app/common/chatFunctions";
import { AgentData } from "@/app/agents/page";
@@ -28,22 +28,44 @@ interface ChatBodyDataProps {
isLoggedIn: boolean;
conversationId?: string;
setQueryToProcess: (query: string) => void;
setImage64: (image64: string) => void;
setImages: (images: string[]) => void;
}
function ChatBodyData(props: ChatBodyDataProps) {
const [message, setMessage] = useState("");
const [image, setImage] = useState<string | null>(null);
const [images, setImages] = useState<string[]>([]);
const [processingMessage, setProcessingMessage] = useState(false);
const [agentMetadata, setAgentMetadata] = useState<AgentData | null>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const setQueryToProcess = props.setQueryToProcess;
const streamedMessages = props.streamedMessages;
const chatHistoryCustomClassName = props.isMobileWidth ? "w-full" : "w-4/6";
useEffect(() => {
if (image) {
props.setImage64(encodeURIComponent(image));
if (images.length > 0) {
const encodedImages = images.map((image) => encodeURIComponent(image));
props.setImages(encodedImages);
}
}, [image, props.setImage64]);
}, [images, props.setImages]);
useEffect(() => {
const storedImages = localStorage.getItem("images");
if (storedImages) {
const parsedImages: string[] = JSON.parse(storedImages);
setImages(parsedImages);
const encodedImages = parsedImages.map((img: string) => encodeURIComponent(img));
props.setImages(encodedImages);
localStorage.removeItem("images");
}
const storedMessage = localStorage.getItem("message");
if (storedMessage) {
setProcessingMessage(true);
setQueryToProcess(storedMessage);
}
}, [setQueryToProcess, props.setImages]);
useEffect(() => {
if (message) {
@@ -78,21 +100,23 @@ function ChatBodyData(props: ChatBodyDataProps) {
setTitle={props.setTitle}
pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages}
customClassName={chatHistoryCustomClassName}
/>
</div>
<div
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-t-2xl rounded-b-none md:rounded-xl`}
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-t-2xl rounded-b-none md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto`}
>
<ChatInputArea
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => setMessage(message)}
sendImage={(image) => setImage(image)}
sendImage={(image) => setImages((prevImages) => [...prevImages, image])}
sendDisabled={processingMessage}
chatOptionsData={props.chatOptionsData}
conversationId={props.conversationId}
agentColor={agentMetadata?.color}
isMobileWidth={props.isMobileWidth}
setUploadedFiles={props.setUploadedFiles}
ref={chatInputRef}
/>
</div>
</>
@@ -109,7 +133,7 @@ export default function SharedChat() {
const [processQuerySignal, setProcessQuerySignal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [paramSlug, setParamSlug] = useState<string | undefined>(undefined);
const [image64, setImage64] = useState<string>("");
const [images, setImages] = useState<string[]>([]);
const locationData = useIPLocationData() || {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -168,7 +192,7 @@ export default function SharedChat() {
completed: false,
timestamp: new Date().toISOString(),
rawQuery: queryToProcess || "",
uploadedImageData: decodeURIComponent(image64),
images: images,
};
setMessages((prevMessages) => [...prevMessages, newStreamMessage]);
setProcessQuerySignal(true);
@@ -195,7 +219,7 @@ export default function SharedChat() {
if (done) {
setQueryToProcess("");
setProcessQuerySignal(false);
setImage64("");
setImages([]);
break;
}
@@ -237,7 +261,7 @@ export default function SharedChat() {
country_code: locationData.countryCode,
timezone: locationData.timezone,
}),
...(image64 && { image: image64 }),
...(images.length > 0 && { image: images }),
};
const response = await fetch(chatAPI, {
@@ -276,6 +300,19 @@ export default function SharedChat() {
<div className={styles.chatBox}>
<div className={styles.chatBoxBody}>
{!isMobileWidth && title && (
<div
className={`${styles.chatTitleWrapper} text-nowrap text-ellipsis overflow-hidden max-w-screen-md grid items-top font-bold mr-8 pt-6 col-auto h-fit`}
>
{title && (
<h2
className={`text-lg text-ellipsis whitespace-nowrap overflow-x-hidden`}
>
{title}
</h2>
)}
</div>
)}
<Suspense fallback={<Loading />}>
<ChatBodyData
conversationId={conversationId}
@@ -287,7 +324,7 @@ export default function SharedChat() {
setTitle={setTitle}
setUploadedFiles={setUploadedFiles}
isMobileWidth={isMobileWidth}
setImage64={setImage64}
setImages={setImages}
/>
</Suspense>
</div>

View File

@@ -75,7 +75,7 @@ div.titleBar {
div.chatBoxBody {
display: grid;
height: 100%;
width: 70%;
width: 95%;
margin: auto;
}

View File

@@ -1,6 +1,6 @@
{
"name": "khoj-ai",
"version": "1.25.0",
"version": "1.26.4",
"private": true,
"scripts": {
"dev": "next dev",
@@ -63,7 +63,8 @@
"swr": "^2.2.5",
"typescript": "^5",
"vaul": "^0.9.1",
"zod": "^3.23.8"
"zod": "^3.23.8",
"@excalidraw/excalidraw": "^0.17.6"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",

View File

@@ -286,6 +286,11 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
"@excalidraw/excalidraw@^0.17.6":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@floating-ui/core@^1.6.0":
version "1.6.8"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12"