mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-04 13:20:17 +00:00
Merge branch 'master' of github.com:khoj-ai/khoj into features/advanced-reasoning
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ div.titleBar {
|
||||
div.chatBoxBody {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
width: 70%;
|
||||
width: 95%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 += ``;
|
||||
} else if (imageJson.intentType === "text-to-image2") {
|
||||
rawResponse += ``;
|
||||
} else if (imageJson.intentType === "text-to-image-v3") {
|
||||
rawResponse = ``;
|
||||
}
|
||||
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);
|
||||
})
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
1297
src/interface/web/app/components/agentCard/agentCard.tsx
Normal file
1297
src/interface/web/app/components/agentCard/agentCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 = `\n\n${message}`;
|
||||
const intentTypeHandlers = {
|
||||
"text-to-image": (msg: string) => ``,
|
||||
"text-to-image2": (msg: string) => ``,
|
||||
"text-to-image-v3": (msg: string) =>
|
||||
``,
|
||||
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 ``;
|
||||
})
|
||||
.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
|
||||
|
||||
24
src/interface/web/app/components/excalidraw/excalidraw.tsx
Normal file
24
src/interface/web/app/components/excalidraw/excalidraw.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -75,7 +75,7 @@ div.titleBar {
|
||||
div.chatBoxBody {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
width: 70%;
|
||||
width: 95%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user