refactor: replacing mobx with jotai (#160)

* refactor: replacing mobx with jotai

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
Co-authored-by: Louis <louis@jan.ai>
This commit is contained in:
NamH 2023-09-13 21:33:53 -07:00 committed by GitHub
parent cc39664ce4
commit d55a83888b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 3749 additions and 7716 deletions

View File

@ -5,8 +5,7 @@ NEXT_PUBLIC_DOWNLOAD_APP_IOS=#
NEXT_PUBLIC_DOWNLOAD_APP_ANDROID=# NEXT_PUBLIC_DOWNLOAD_APP_ANDROID=#
NEXT_PUBLIC_GRAPHQL_ENGINE_URL=http://localhost:8080/v1/graphql NEXT_PUBLIC_GRAPHQL_ENGINE_URL=http://localhost:8080/v1/graphql
NEXT_PUBLIC_GRAPHQL_ENGINE_WEB_SOCKET_URL=ws://localhost:8080/v1/graphql NEXT_PUBLIC_GRAPHQL_ENGINE_WEB_SOCKET_URL=ws://localhost:8080/v1/graphql
OPENAPI_ENDPOINT=http://host.docker.internal:8000/v1 NEXT_PUBLIC_OPENAPI_ENDPOINT=http://localhost:8000/v1/chat/completions
OPENAPI_KEY=openapikey
KEYCLOAK_CLIENT_ID=hasura KEYCLOAK_CLIENT_ID=hasura
KEYCLOAK_CLIENT_SECRET=oMtCPAV7diKpE564SBspgKj4HqlKM4Hy KEYCLOAK_CLIENT_SECRET=oMtCPAV7diKpE564SBspgKj4HqlKM4Hy
AUTH_ISSUER=http://localhost:8088/realms/$KEYCLOAK_CLIENT_ID AUTH_ISSUER=http://localhost:8088/realms/$KEYCLOAK_CLIENT_ID

View File

@ -77,9 +77,7 @@ Replace above configuration with your actual infrastructure.
| [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) | UI | ^0.5.9 | | [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) | UI | ^0.5.9 |
| [embla-carousel](https://www.embla-carousel.com/) | UI | ^8.0.0-rc11 | | [embla-carousel](https://www.embla-carousel.com/) | UI | ^8.0.0-rc11 |
| [@apollo/client](https://www.apollographql.com/docs/react/) | State management | ^3.8.1 | | [@apollo/client](https://www.apollographql.com/docs/react/) | State management | ^3.8.1 |
| [mobx](https://mobx.js.org/README.html) | State management | ^6.10.0 | | [jotai](https://jotai.org/) | State management | ^2.4.0 |
| [mobx-react-lite](https://www.npmjs.com/package/mobx-react-lite) | State management | ^4.0.3 |
| [mobx-state-tree](https://mobx-state-tree.js.org/) | State management | ^5.1.8 |
## Deploy to Netlify ## Deploy to Netlify

View File

@ -1,69 +1,29 @@
"use client"; import { MenuAdvancedPrompt } from "../MenuAdvancedPrompt";
import { useCallback } from "react"; import { useForm } from "react-hook-form";
import Image from "next/image"; import BasicPromptButton from "../BasicPromptButton";
import { useStore } from "@/_models/RootStore"; import PrimaryButton from "../PrimaryButton";
import { observer } from "mobx-react-lite";
import { MenuAdvancedPrompt } from "../MenuAdvancedPrompt"; const AdvancedPrompt: React.FC = () => {
import { useForm } from "react-hook-form"; const { register, handleSubmit } = useForm();
import { useMutation } from "@apollo/client";
import { CreateMessageDocument, CreateMessageMutation } from "@/graphql"; const onSubmit = (data: any) => {};
export const AdvancedPrompt: React.FC = observer(() => { return (
const { register, handleSubmit } = useForm(); <form
const { historyStore } = useStore(); className="w-[288px] h-screen flex flex-col border-r border-gray-200"
onSubmit={handleSubmit(onSubmit)}
const onAdvancedPrompt = useCallback(() => { >
historyStore.toggleAdvancedPrompt(); <BasicPromptButton />
}, []); <MenuAdvancedPrompt register={register} />
<div className="py-3 px-2 flex flex-none gap-3 items-center justify-between border-t border-gray-200">
const [createMessageMutation] = useMutation<CreateMessageMutation>( <PrimaryButton
CreateMessageDocument fullWidth={true}
); title="Generate"
const onSubmit = (data: any) => { onClick={() => handleSubmit(onSubmit)}
historyStore.sendControlNetPrompt( />
createMessageMutation, </div>
data.prompt, </form>
data.negativePrompt, );
data.fileInput[0] };
);
}; export default AdvancedPrompt;
return (
<form
className={`${
historyStore.showAdvancedPrompt ? "w-[288px]" : "hidden"
} h-screen flex flex-col border-r border-gray-200`}
onSubmit={handleSubmit(onSubmit)}
>
<button
onClick={onAdvancedPrompt}
className="flex items-center mx-2 mt-3 mb-[10px] flex-none gap-1 text-xs leading-[18px] text-[#6B7280]"
>
<Image src={"/icons/chevron-left.svg"} width={20} height={20} alt="" />
<span className="font-semibold text-gray-500 text-xs">
BASIC PROMPT
</span>
</button>
<div className="flex flex-col justify-start flex-1 p-3 gap-[10px] overflow-x-hidden scroll">
<MenuAdvancedPrompt register={register} />
</div>
<div className="py-3 px-2 flex flex-none gap-3 items-center justify-between border-t border-gray-200">
<button className="w-1/2 flex items-center text-gray-900 py-2 px-3 rounded-lg gap-1 justify-center bg-gray-100 text-sm leading-5">
<Image
src={"/icons/unicorn_arrow-random.svg"}
width={16}
height={16}
alt=""
/>
Random
</button>
<button
className="w-1/2 flex items-center text-gray-900 justify-center py-2 px-3 rounded-lg gap-1 bg-yellow-300 text-sm leading-5"
onClick={(e) => handleSubmit(onSubmit)(e)}
>
Generate
</button>
</div>
</form>
);
});

View File

@ -1,27 +1,27 @@
import { FieldValues, UseFormRegister } from "react-hook-form"; import { FieldValues, UseFormRegister } from "react-hook-form";
type Props = { type Props = {
formId?: string; formId?: string;
height: number; height: number;
title: string; title: string;
placeholder: string; placeholder: string;
register: UseFormRegister<FieldValues>; register: UseFormRegister<FieldValues>;
}; };
export const AdvancedTextArea: React.FC<Props> = ({ export const AdvancedTextArea: React.FC<Props> = ({
formId = "", formId = "",
height, height,
placeholder, placeholder,
title, title,
register, register,
}) => ( }) => (
<div className="w-full flex flex-col pt-3 gap-1"> <div className="w-full flex flex-col pt-3 gap-1">
<label className="text-sm leading-5 text-gray-800">{title}</label> <label className="text-sm leading-5 text-gray-800">{title}</label>
<textarea <textarea
style={{ height: `${height}px` }} style={{ height: `${height}px` }}
className="rounded-lg py-[13px] px-5 border outline-none resize-none border-gray-300 bg-gray-50 placeholder:gray-400 text-sm font-normal" className="rounded-lg py-[13px] px-5 border outline-none resize-none border-gray-300 bg-gray-50 placeholder:gray-400 text-sm font-normal"
placeholder={placeholder} placeholder={placeholder}
{...register(formId, { required: formId === "prompt" ? true : false })} {...register(formId, { required: formId === "prompt" ? true : false })}
/> />
</div> </div>
); );

View File

@ -1,79 +1,79 @@
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript"; import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import useGetModelApiInfo from "@/_hooks/useGetModelApiInfo"; import useGetModelApiInfo from "@/_hooks/useGetModelApiInfo";
SyntaxHighlighter.registerLanguage("javascript", js); SyntaxHighlighter.registerLanguage("javascript", js);
const ApiPane: React.FC = () => { const ApiPane: React.FC = () => {
const [expend, setExpend] = useState(true); const [expend, setExpend] = useState(true);
const { data } = useGetModelApiInfo(); const { data } = useGetModelApiInfo();
const [highlightCode, setHighlightCode] = useState(data[0]); const [highlightCode, setHighlightCode] = useState(data[0]);
return ( return (
<div className="h-full flex flex-col relative"> <div className="h-full flex flex-col relative">
<div className="absolute top-0 left-0 h-full w-full overflow-x-hidden scroll"> <div className="absolute top-0 left-0 h-full w-full overflow-x-hidden scroll">
<button <button
onClick={() => setExpend(!expend)} onClick={() => setExpend(!expend)}
className="flex items-center flex-none" className="flex items-center flex-none"
> >
<Image <Image
src={"/icons/unicorn_angle-down.svg"} src={"/icons/unicorn_angle-down.svg"}
width={24} width={24}
height={24} height={24}
alt="" alt=""
/> />
<span>Request</span> <span>Request</span>
</button> </button>
<div <div
className={`${ className={`${
expend ? "block" : "hidden" expend ? "block" : "hidden"
} bg-[#1F2A37] rounded-lg w-full flex-1`} } bg-[#1F2A37] rounded-lg w-full flex-1`}
> >
<div className="p-2 flex justify-between flex-1"> <div className="p-2 flex justify-between flex-1">
<div className="flex"> <div className="flex">
{data.map((item, index) => ( {data.map((item, index) => (
<button <button
className={`py-1 text-xs text-[#9CA3AF] px-2 flex gap-[10px] rounded ${ className={`py-1 text-xs text-[#9CA3AF] px-2 flex gap-[10px] rounded ${
highlightCode?.type === item.type highlightCode?.type === item.type
? "bg-[#374151] text-white" ? "bg-[#374151] text-white"
: "" : ""
}`} }`}
key={index} key={index}
onClick={() => setHighlightCode(item)} onClick={() => setHighlightCode(item)}
> >
{item.type} {item.type}
</button> </button>
))} ))}
</div> </div>
<button <button
onClick={() => onClick={() =>
navigator.clipboard.writeText(highlightCode?.stringCode) navigator.clipboard.writeText(highlightCode?.stringCode)
} }
> >
<Image <Image
src={"/icons/unicorn_clipboard-alt.svg"} src={"/icons/unicorn_clipboard-alt.svg"}
width={24} width={24}
height={24} height={24}
alt="" alt=""
/> />
</button> </button>
</div> </div>
<SyntaxHighlighter <SyntaxHighlighter
className="w-full bg-transparent overflow-x-hidden scroll resize-none" className="w-full bg-transparent overflow-x-hidden scroll resize-none"
language="jsx" language="jsx"
style={atomOneDark} style={atomOneDark}
customStyle={{ padding: "12px", background: "transparent" }} customStyle={{ padding: "12px", background: "transparent" }}
wrapLongLines={true} wrapLongLines={true}
> >
{highlightCode?.stringCode} {highlightCode?.stringCode}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default ApiPane; export default ApiPane;

View File

@ -1,15 +1,15 @@
type Props = { type Props = {
title: string; title: string;
description: string; description: string;
}; };
export const ApiStep: React.FC<Props> = ({ description, title }) => { export const ApiStep: React.FC<Props> = ({ description, title }) => {
return ( return (
<div className="gap-2 flex flex-col"> <div className="gap-2 flex flex-col">
<span className="text-[#8A8A8A]">{title}</span> <span className="text-[#8A8A8A]">{title}</span>
<div className="flex flex-col gap-[10px] p-[18px] bg-[#F9F9F9] overflow-y-hidden"> <div className="flex flex-col gap-[10px] p-[18px] bg-[#F9F9F9] overflow-y-hidden">
<pre className="text-sm leading-5 text-black">{description}</pre> <pre className="text-sm leading-5 text-black">{description}</pre>
</div> </div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,40 @@
"use client";
import {
currentConversationAtom,
showingAdvancedPromptAtom,
} from "@/_helpers/JotaiWrapper";
import { useAtomValue, useSetAtom } from "jotai";
import SecondaryButton from "../SecondaryButton";
import SendButton from "../SendButton";
import { ProductType } from "@/_models/Product";
const BasicPromptAccessories: React.FC = () => {
const setShowingAdvancedPrompt = useSetAtom(showingAdvancedPromptAtom);
const currentConversation = useAtomValue(currentConversationAtom);
const shouldShowAdvancedPrompt =
currentConversation?.product.type === ProductType.ControlNet;
return (
<div
style={{
backgroundColor: "#F8F8F8",
borderWidth: 1,
borderColor: "#D1D5DB",
}}
className="flex justify-between py-2 pl-3 pr-2 rounded-b-lg"
>
{shouldShowAdvancedPrompt && (
<SecondaryButton
title="Advanced"
onClick={() => setShowingAdvancedPrompt(true)}
/>
)}
<div className="flex justify-end items-center space-x-1 w-full pr-3" />
{!shouldShowAdvancedPrompt && <SendButton />}
</div>
);
};
export default BasicPromptAccessories;

View File

@ -0,0 +1,20 @@
import React from "react";
import { useSetAtom } from "jotai";
import { ChevronLeftIcon } from "@heroicons/react/24/outline";
import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper";
const BasicPromptButton: React.FC = () => {
const setShowingAdvancedPrompt = useSetAtom(showingAdvancedPromptAtom);
return (
<button
onClick={() => setShowingAdvancedPrompt(false)}
className="flex items-center mx-2 mt-3 mb-[10px] flex-none gap-1 text-xs leading-[18px] text-[#6B7280]"
>
<ChevronLeftIcon width={20} height={20} />
<span className="font-semibold text-gray-500 text-xs">BASIC PROMPT</span>
</button>
);
};
export default React.memo(BasicPromptButton);

View File

@ -0,0 +1,38 @@
"use client";
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
import useSendChatMessage from "@/_hooks/useSendChatMessage";
import { useAtom } from "jotai";
const BasicPromptInput: React.FC = () => {
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom);
const { sendChatMessage } = useSendChatMessage();
const handleMessageChange = (event: any) => {
setCurrentPrompt(event.target.value);
};
const handleKeyDown = (event: any) => {
if (event.key === "Enter") {
if (!event.shiftKey) {
event.preventDefault();
sendChatMessage();
}
}
};
return (
<textarea
onKeyDown={handleKeyDown}
value={currentPrompt}
onChange={handleMessageChange}
rows={2}
name="comment"
id="comment"
className="overflow-hidden block w-full scroll resize-none border-0 bg-transparent py-1.5 text-gray-900 transition-height duration-200 placeholder:text-gray-400 sm:text-sm sm:leading-6 dark:text-white"
placeholder="Add your comment..."
/>
);
};
export default BasicPromptInput;

View File

@ -1,45 +1,39 @@
import Image from "next/image"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import React, { PropsWithChildren } from "react"; import React, { PropsWithChildren } from "react";
type PropType = PropsWithChildren< type PropType = PropsWithChildren<
React.DetailedHTMLProps< React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
> >
>; >;
export const PrevButton: React.FC<PropType> = (props) => { export const PrevButton: React.FC<PropType> = (props) => {
const { children, ...restProps } = props; const { children, ...restProps } = props;
return ( return (
<button <button
className="embla__button embla__button--prev" className="embla__button embla__button--prev"
type="button" type="button"
{...restProps} {...restProps}
> >
<Image <ChevronLeftIcon width={20} height={20} />
className="rotate-180" {children}
src={"/icons/chevron-right.svg"} </button>
width={20} );
height={20} };
alt=""
/> export const NextButton: React.FC<PropType> = (props) => {
{children} const { children, ...restProps } = props;
</button>
); return (
}; <button
className="embla__button embla__button--next"
export const NextButton: React.FC<PropType> = (props) => { type="button"
const { children, ...restProps } = props; {...restProps}
>
return ( <ChevronRightIcon width={20} height={20} />
<button {children}
className="embla__button embla__button--next" </button>
type="button" );
{...restProps} };
>
<Image src={"/icons/chevron-right.svg"} width={20} height={20} alt="" />
{children}
</button>
);
};

View File

@ -1,25 +1,25 @@
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline"; import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
export const ThemeChanger: React.FC = () => { export const ThemeChanger: React.FC = () => {
const { theme, setTheme, systemTheme } = useTheme(); const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = theme === "system" ? systemTheme : theme; const currentTheme = theme === "system" ? systemTheme : theme;
if (currentTheme === "dark") { if (currentTheme === "dark") {
return ( return (
<SunIcon <SunIcon
className="h-6 w-6" className="h-6 w-6"
aria-hidden="true" aria-hidden="true"
onClick={() => setTheme("light")} onClick={() => setTheme("light")}
/> />
); );
} }
return ( return (
<MoonIcon <MoonIcon
className="h-6 w-6" className="h-6 w-6"
aria-hidden="true" aria-hidden="true"
onClick={() => setTheme("dark")} onClick={() => setTheme("dark")}
/> />
); );
}; };

View File

@ -1,186 +1,47 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; "use client";
import { useStore } from "@/_models/RootStore";
import { observer } from "mobx-react-lite";
import { ChatMessage, MessageStatus, MessageType } from "@/_models/ChatMessage";
import SimpleImageMessage from "../SimpleImageMessage";
import SimpleTextMessage from "../SimpleTextMessage";
import { Instance } from "mobx-state-tree";
import { GenerativeSampleContainer } from "../GenerativeSampleContainer";
import { AiModelType } from "@/_models/Product";
import SampleLlmContainer from "@/_components/SampleLlmContainer";
import SimpleControlNetMessage from "../SimpleControlNetMessage";
import {
GetConversationMessagesQuery,
GetConversationMessagesDocument,
} from "@/graphql";
import { useLazyQuery } from "@apollo/client";
import LoadingIndicator from "../LoadingIndicator";
import StreamTextMessage from "../StreamTextMessage";
type Props = { import React, { useCallback, useRef, useState } from "react";
onPromptSelected: (prompt: string) => void; import ChatItem from "../ChatItem";
}; import { ChatMessage } from "@/_models/ChatMessage";
import useChatMessages from "@/_hooks/useChatMessages";
import { currentChatMessagesAtom } from "@/_helpers/JotaiWrapper";
import { useAtomValue } from "jotai";
export const ChatBody: React.FC<Props> = observer(({ onPromptSelected }) => { const ChatBody: React.FC = () => {
const ref = useRef<HTMLDivElement>(null); const messages = useAtomValue(currentChatMessagesAtom);
const scrollRef = useRef<HTMLDivElement>(null); const [offset, setOffset] = useState(0);
const [height, setHeight] = useState(0); const { loading, hasMore } = useChatMessages(offset);
const { historyStore } = useStore(); const intersectObs = useRef<any>(null);
const refSmooth = useRef<HTMLDivElement>(null);
const [heightContent, setHeightContent] = useState(0);
const refContent = useRef<HTMLDivElement>(null); const lastPostRef = useCallback(
const convo = historyStore.getActiveConversation(); (message: ChatMessage) => {
const [getConversationMessages] = useLazyQuery<GetConversationMessagesQuery>( if (loading) return;
GetConversationMessagesDocument
if (intersectObs.current) intersectObs.current.disconnect();
intersectObs.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
setOffset((prevOffset) => prevOffset + 5);
}
});
if (message) intersectObs.current.observe(message);
},
[loading, hasMore]
); );
useEffect(() => { const content = messages.map((message, index) => {
refSmooth.current?.scrollIntoView({ behavior: "instant" }); if (messages.length === index + 1) {
}, [heightContent]); return <ChatItem ref={lastPostRef} message={message} key={message.id} />;
useLayoutEffect(() => {
if (refContent.current) {
setHeightContent(refContent.current?.offsetHeight);
} }
return <ChatItem message={message} key={message.id} />;
}); });
useLayoutEffect(() => {
if (!ref.current) return;
setHeight(ref.current?.offsetHeight);
}, []);
const loadFunc = () => {
historyStore.fetchMoreMessages(getConversationMessages);
};
const messages = historyStore.getActiveMessages();
const shouldShowSampleContainer = messages.length === 0;
const shouldShowImageSampleContainer =
shouldShowSampleContainer &&
convo &&
convo.product.type === AiModelType.GenerativeArt;
const model = convo?.product;
const handleScroll = () => {
if (!scrollRef.current) return;
if (
scrollRef.current?.clientHeight - scrollRef.current?.scrollTop + 1 >=
scrollRef.current?.scrollHeight
) {
loadFunc();
}
};
useEffect(() => {
loadFunc();
scrollRef.current?.addEventListener("scroll", handleScroll);
return () => {
scrollRef.current?.removeEventListener("scroll", handleScroll);
};
}, [scrollRef.current]);
return ( return (
<div className="flex-grow flex flex-col h-fit" ref={ref}> <div className="flex flex-col-reverse flex-1 py-4 overflow-y-auto scroll">
{shouldShowSampleContainer && model ? ( {content}
shouldShowImageSampleContainer ? (
<GenerativeSampleContainer
model={convo?.product}
onPromptSelected={onPromptSelected}
/>
) : (
<SampleLlmContainer
model={convo?.product}
onPromptSelected={onPromptSelected}
/>
)
) : (
<div
className="flex flex-col-reverse scroll"
style={{
height: height + "px",
overflowX: "hidden",
}}
ref={scrollRef}
>
<div
className="flex flex-col justify-end gap-8 py-2"
ref={refContent}
>
{messages.map((message, index) => renderItem(index, message))}
<div ref={refSmooth}>
{convo?.isWaitingForModelResponse && (
<div className="w-[50px] h-[50px] px-2 flex flex-row items-start justify-start">
<LoadingIndicator />
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
});
const renderItem = (
index: number,
{
id,
messageType,
senderAvatarUrl,
senderName,
createdAt,
imageUrls,
text,
status,
}: Instance<typeof ChatMessage>
) => {
switch (messageType) {
case MessageType.ImageWithText:
return (
<SimpleControlNetMessage
key={index}
avatarUrl={senderAvatarUrl ?? "/icons/app_icon.svg"}
senderName={senderName}
createdAt={createdAt}
imageUrls={imageUrls ?? []}
text={text ?? ""}
/>
);
case MessageType.Image:
return (
<SimpleImageMessage
key={index}
avatarUrl={senderAvatarUrl ?? "/icons/app_icon.svg"}
senderName={senderName}
createdAt={createdAt}
imageUrls={imageUrls ?? []}
text={text}
/>
);
case MessageType.Text:
return status === MessageStatus.Ready ? (
<SimpleTextMessage
key={index}
avatarUrl={senderAvatarUrl ?? "/icons/app_icon.svg"}
senderName={senderName}
createdAt={createdAt}
text={text}
/>
) : (
<StreamTextMessage
key={index}
id={id}
avatarUrl={senderAvatarUrl ?? "/icons/app_icon.svg"}
senderName={senderName}
createdAt={createdAt}
text={text}
/>
);
default:
return null;
}
}; };
export default ChatBody;

View File

@ -0,0 +1,66 @@
import SimpleControlNetMessage from "../SimpleControlNetMessage";
import SimpleImageMessage from "../SimpleImageMessage";
import SimpleTextMessage from "../SimpleTextMessage";
import { ChatMessage, MessageType } from "@/_models/ChatMessage";
import StreamTextMessage from "../StreamTextMessage";
import { useAtom } from "jotai";
import { currentStreamingMessageAtom } from "@/_helpers/JotaiWrapper";
export default function renderChatMessage({
id,
messageType,
senderAvatarUrl,
senderName,
createdAt,
imageUrls,
text,
status,
}: ChatMessage): React.ReactNode {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [message, _] = useAtom(currentStreamingMessageAtom);
switch (messageType) {
case MessageType.ImageWithText:
return (
<SimpleControlNetMessage
key={id}
avatarUrl={senderAvatarUrl}
senderName={senderName}
createdAt={createdAt}
imageUrls={imageUrls ?? []}
text={text ?? ""}
/>
);
case MessageType.Image:
return (
<SimpleImageMessage
key={id}
avatarUrl={senderAvatarUrl}
senderName={senderName}
createdAt={createdAt}
imageUrls={imageUrls ?? []}
text={text}
/>
);
case MessageType.Text:
return id !== message?.id ? (
<SimpleTextMessage
key={id}
avatarUrl={senderAvatarUrl}
senderName={senderName}
createdAt={createdAt}
text={text}
/>
) : (
<StreamTextMessage
key={id}
id={id}
avatarUrl={senderAvatarUrl}
senderName={senderName}
createdAt={createdAt}
text={text}
/>
);
default:
return null;
}
}

View File

@ -1,91 +1,30 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { ChatBody } from "../ChatBody"; import ModelDetailSideBar from "../ModelDetailSideBar";
import { InputToolbar } from "../InputToolbar"; import ProductOverview from "../ProductOverview";
import { UserToolbar } from "../UserToolbar"; import { useAtomValue } from "jotai";
import ModelMenu from "../ModelMenu";
import { useStore } from "@/_models/RootStore";
import { observer } from "mobx-react-lite";
import ConfirmDeleteConversationModal from "../ConfirmDeleteConversationModal";
import { ModelDetailSideBar } from "../ModelDetailSideBar";
import NewChatBlankState from "../NewChatBlankState";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
import { import {
DeleteConversationMutation, getActiveConvoIdAtom,
DeleteConversationDocument, showingProductDetailAtom,
} from "@/graphql"; } from "@/_helpers/JotaiWrapper";
import { useMutation } from "@apollo/client"; import { ReactNode } from "react";
const ChatContainer: React.FC = observer(() => { type Props = {
const [prefillPrompt, setPrefillPrompt] = useState(""); children: ReactNode;
const { historyStore } = useStore(); };
const { user } = useGetCurrentUser();
const showBodyChat = historyStore.activeConversationId != null;
const conversation = historyStore.getActiveConversation();
const [deleteConversation] = useMutation<DeleteConversationMutation>(
DeleteConversationDocument
);
useEffect(() => { export default function ChatContainer({ children }: Props) {
if (!user) { const activeConvoId = useAtomValue(getActiveConvoIdAtom);
historyStore.clearAllConversations(); const showingProductDetail = useAtomValue(showingProductDetailAtom);
}
}, [user]);
const [open, setOpen] = useState(false); if (!activeConvoId) {
return <ProductOverview />;
const onConfirmDelete = () => { }
setPrefillPrompt("");
historyStore.closeModelDetail();
if (conversation?.id) {
deleteConversation({ variables: { id: conversation.id } }).then(() =>
historyStore.deleteConversationById(conversation.id)
);
}
setOpen(false);
};
const onSuggestPromptClick = (prompt: string) => {
if (prompt !== prefillPrompt) {
setPrefillPrompt(prompt);
}
};
return ( return (
<div className="flex flex-1 h-full overflow-y-hidden"> <div className="flex flex-1 overflow-hidden">
<ConfirmDeleteConversationModal {children}
open={open} {showingProductDetail ? <ModelDetailSideBar /> : null}
setOpen={setOpen}
onConfirmDelete={onConfirmDelete}
/>
{showBodyChat ? (
<div className="flex-1 flex flex-col w-full">
<div className="flex w-full overflow-hidden flex-shrink-0 px-3 py-1 border-b dark:bg-gray-950 border-gray-200 bg-white shadow-sm sm:px-3 lg:px-3">
{/* Separator */}
<div
className="h-full w-px bg-gray-200 lg:hidden"
aria-hidden="true"
/>
<div className="flex justify-between self-stretch flex-1">
<UserToolbar />
<ModelMenu
onDeleteClick={() => setOpen(true)}
onCreateConvClick={() => {}}
/>
</div>
</div>
<div className="flex flex-col h-full px-1 sm:px-2 lg:px-3 overflow-hidden">
<ChatBody onPromptSelected={onSuggestPromptClick} />
<InputToolbar prefillPrompt={prefillPrompt} />
</div>
</div>
) : (
<NewChatBlankState />
)}
<ModelDetailSideBar onPromptClick={onSuggestPromptClick} />
</div> </div>
); );
}); }
export default ChatContainer;

View File

@ -0,0 +1,19 @@
import React, { forwardRef } from "react";
import renderChatMessage from "../ChatBody/renderChatMessage";
import { ChatMessage } from "@/_models/ChatMessage";
type Props = {
message: ChatMessage;
};
type Ref = HTMLDivElement;
const ChatItem = forwardRef<Ref, Props>(({ message }, ref) => {
const item = renderChatMessage(message);
const content = ref ? <div ref={ref}>{item}</div> : item;
return content;
});
export default ChatItem;

View File

@ -1,32 +1,30 @@
import { useStore } from "@/_models/RootStore"; import {
getActiveConvoIdAtom,
setActiveConvoIdAtom,
} from "@/_helpers/JotaiWrapper";
import { useAtomValue, useSetAtom } from "jotai";
import Image from "next/image"; import Image from "next/image";
import React from "react";
type Props = { type Props = {
imageUrl: string; imageUrl: string;
isSelected: boolean;
conversationId: string; conversationId: string;
}; };
const CompactHistoryItem: React.FC<Props> = ({ const CompactHistoryItem: React.FC<Props> = ({ imageUrl, conversationId }) => {
imageUrl, const activeConvoId = useAtomValue(getActiveConvoIdAtom);
isSelected, const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
conversationId,
}) => { const isSelected = activeConvoId === conversationId;
const { historyStore } = useStore();
const onClick = () => {
historyStore.setActiveConversationId(conversationId);
};
return ( return (
<button <button
onClick={onClick} onClick={() => setActiveConvoId(conversationId)}
className={`${ className={`${
isSelected ? "bg-gray-100" : "bg-transparent" isSelected ? "bg-gray-100" : "bg-transparent"
} p-2 rounded-lg`} } w-14 h-14 rounded-lg`}
> >
<Image <Image
className="rounded-full" className="rounded-full mx-auto"
src={imageUrl} src={imageUrl}
width={36} width={36}
height={36} height={36}
@ -36,4 +34,4 @@ const CompactHistoryItem: React.FC<Props> = ({
); );
}; };
export default React.memo(CompactHistoryItem); export default CompactHistoryItem;

View File

@ -0,0 +1,21 @@
import { useAtomValue } from "jotai";
import CompactHistoryItem from "../CompactHistoryItem";
import { userConversationsAtom } from "@/_helpers/JotaiWrapper";
const CompactHistoryList: React.FC = () => {
const conversations = useAtomValue(userConversationsAtom);
return (
<div className="flex flex-col flex-1 gap-1 mt-3">
{conversations.map(({ id, product }) => (
<CompactHistoryItem
key={id}
conversationId={id}
imageUrl={product.avatarUrl ?? ""}
/>
))}
</div>
);
};
export default CompactHistoryList;

View File

@ -1,13 +1,13 @@
import React from "react"; import React from "react";
import JanImage from "../JanImage"; import JanImage from "../JanImage";
import { setActiveConvoIdAtom } from "@/_helpers/JotaiWrapper";
import { useSetAtom } from "jotai";
type Props = { const CompactLogo: React.FC = () => {
onClick: () => void; const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
};
const CompactLogo: React.FC<Props> = ({ onClick }) => {
return ( return (
<button onClick={onClick}> <button onClick={() => setActiveConvoId(undefined)}>
<JanImage imageUrl="/icons/app_icon.svg" width={28} height={28} /> <JanImage imageUrl="/icons/app_icon.svg" width={28} height={28} />
</button> </button>
); );

View File

@ -1,33 +1,11 @@
"use client" import CompactHistoryList from "../CompactHistoryList";
import { observer } from "mobx-react-lite";
import CompactLogo from "../CompactLogo"; import CompactLogo from "../CompactLogo";
import CompactHistoryItem from "../CompactHistoryItem";
import { useStore } from "@/_models/RootStore";
export const CompactSideBar: React.FC = observer(() => { const CompactSideBar: React.FC = () => (
const { historyStore } = useStore(); <div className="h-screen w-16 border-r border-gray-300 flex flex-col items-center pt-3 gap-3">
<CompactLogo />
<CompactHistoryList />
</div>
);
const onLogoClick = () => { export default CompactSideBar;
historyStore.clearActiveConversationId();
};
return (
<div
className={`${
!historyStore.showAdvancedPrompt ? "hidden" : "block"
} h-screen border-r border-gray-300 flex flex-col items-center pt-3 gap-3`}
>
<CompactLogo onClick={onLogoClick} />
<div className="flex flex-col gap-1 mx-1 mt-3 overflow-x-hidden">
{historyStore.conversations.map(({ id, product: aiModel }) => (
<CompactHistoryItem
key={id}
conversationId={id}
imageUrl={aiModel.avatarUrl ?? ""}
isSelected={historyStore.activeConversationId === id}
/>
))}
</div>
</div>
);
});

View File

@ -1,27 +1,26 @@
import { showConfirmDeleteConversationModalAtom } from "@/_helpers/JotaiWrapper";
import useDeleteConversation from "@/_hooks/useDeleteConversation";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import React, { Fragment, useRef, useState } from "react"; import { useAtom } from "jotai";
import React, { Fragment, useRef } from "react";
type Props = { const ConfirmDeleteConversationModal: React.FC = () => {
open: boolean; const [show, setShow] = useAtom(showConfirmDeleteConversationModalAtom);
setOpen: (open: boolean) => void;
onConfirmDelete: () => void;
};
const ConfirmDeleteConversationModal: React.FC<Props> = ({
open,
setOpen,
onConfirmDelete,
}) => {
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const { deleteConvo } = useDeleteConversation();
const onConfirmDelete = () => {
deleteConvo().then(() => setShow(false));
};
return ( return (
<Transition.Root show={open} as={Fragment}> <Transition.Root show={show} as={Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-10"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={setOpen} onClose={setShow}
> >
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -81,7 +80,7 @@ const ConfirmDeleteConversationModal: React.FC<Props> = ({
<button <button
type="button" type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto" className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={() => setOpen(false)} onClick={() => setShow(false)}
ref={cancelButtonRef} ref={cancelButtonRef}
> >
Cancel Cancel

View File

@ -1,22 +1,21 @@
import React, { Fragment } from "react"; import React, { Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper";
import { useAtom } from "jotai";
import useSignOut from "@/_hooks/useSignOut";
type Props = { const ConfirmSignOutModal: React.FC = () => {
open: boolean; const [show, setShow] = useAtom(showConfirmSignOutModalAtom);
setOpen: (open: boolean) => void; const { signOut } = useSignOut();
onConfirm: () => void;
};
const ConfirmSignOutModal: React.FC<Props> = ({ open, setOpen, onConfirm }) => {
const onLogOutClick = () => { const onLogOutClick = () => {
onConfirm(); signOut().then(() => setShow(false));
setOpen(false);
}; };
return ( return (
<Transition.Root show={open} as={Fragment}> <Transition.Root show={show} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen}> <Dialog as="div" className="relative z-10" onClose={setShow}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -73,7 +72,7 @@ const ConfirmSignOutModal: React.FC<Props> = ({ open, setOpen, onConfirm }) => {
<button <button
type="button" type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto" className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={() => setOpen(false)} onClick={() => setShow(false)}
> >
Cancel Cancel
</button> </button>

View File

@ -1,18 +1,16 @@
import React from "react"; import React from "react";
import Image from "next/image"; import Image from "next/image";
import {
ProductDetailFragment,
} from "@/graphql";
import useCreateConversation from "@/_hooks/useCreateConversation"; import useCreateConversation from "@/_hooks/useCreateConversation";
import { Product } from "@/_models/Product";
type Props = { type Props = {
product: ProductDetailFragment; product: Product;
}; };
const ConversationalCard: React.FC<Props> = ({ product }) => { const ConversationalCard: React.FC<Props> = ({ product }) => {
const { requestCreateConvo } = useCreateConversation(); const { requestCreateConvo } = useCreateConversation();
const { name, image_url, description } = product; const { name, avatarUrl, description } = product;
return ( return (
<button <button
@ -25,7 +23,7 @@ const ConversationalCard: React.FC<Props> = ({ product }) => {
<Image <Image
width={32} width={32}
height={32} height={32}
src={image_url ?? ""} src={avatarUrl ?? ""}
className="rounded-full" className="rounded-full"
alt="" alt=""
/> />

View File

@ -1,22 +1,22 @@
import { Product } from "@/_models/Product";
import ConversationalCard from "../ConversationalCard"; import ConversationalCard from "../ConversationalCard";
import Image from "next/image"; import { ChatBubbleBottomCenterTextIcon } from "@heroicons/react/24/outline";
import { ProductDetailFragment } from "@/graphql";
type Props = { type Props = {
products: ProductDetailFragment[]; products: Product[];
}; };
const ConversationalList: React.FC<Props> = ({ products }) => ( const ConversationalList: React.FC<Props> = ({ products }) => (
<> <>
<div className="flex items-center gap-3 mt-8 mb-2"> <div className="flex items-center gap-3 mt-8 mb-2">
<Image src={"/icons/messicon.svg"} width={24} height={24} alt="" /> <ChatBubbleBottomCenterTextIcon width={24} height={24} className="ml-6" />
<span className="font-semibold text-gray-900 dark:text-white"> <span className="font-semibold text-gray-900 dark:text-white">
Conversational Conversational
</span> </span>
</div> </div>
<div className="mt-2 flex w-full gap-2 overflow-x-scroll scroll overflow-hidden"> <div className="mt-2 pl-6 flex w-full gap-2 overflow-x-scroll scroll overflow-hidden">
{products.map((item) => ( {products.map((item) => (
<ConversationalCard key={item.name} product={item} /> <ConversationalCard key={item.slug} product={item} />
))} ))}
</div> </div>
</> </>

View File

@ -1,37 +0,0 @@
import React, { useState } from "react";
import { useStore } from "@/_models/RootStore";
type Props = {
targetRef: React.RefObject<HTMLDivElement>;
};
export const Draggable: React.FC<Props> = ({ targetRef }) => {
const { historyStore } = useStore();
const [initialPos, setInitialPos] = useState<number | null>(null);
const [initialSize, setInitialSize] = useState<number | null>(null);
const [width, setWidth] = useState<number>(0);
const initial = (e: React.DragEvent<HTMLDivElement>) => {
setInitialPos(e.clientX);
setInitialSize(targetRef.current?.offsetWidth ?? 0);
};
const resize = (e: React.DragEvent<HTMLDivElement>) => {
if (initialPos !== null && initialSize !== null) {
setWidth(initialSize - (e.clientX - initialPos));
targetRef.current!.style.width = `${width}px`;
}
if (width <= 270) {
historyStore.closeModelDetail();
}
};
return (
<div
className="absolute left-0 top-0 w-1 h-full cursor-ew-resize"
draggable={true}
onDrag={resize}
onDragStart={initial}
></div>
);
};

View File

@ -1,64 +1,64 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import Image from "next/image"; import Image from "next/image";
function classNames(...classes: any) { function classNames(...classes: any) {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
} }
type Props = { type Props = {
title: string; title: string;
data: string[]; data: string[];
}; };
export const DropdownsList: React.FC<Props> = ({ data, title }) => { export const DropdownsList: React.FC<Props> = ({ data, title }) => {
const [checked, setChecked] = useState(data[0]); const [checked, setChecked] = useState(data[0]);
return ( return (
<Menu as="div" className="relative w-full text-left"> <Menu as="div" className="relative w-full text-left">
<div className="pt-2 gap-2 flex flex-col"> <div className="pt-2 gap-2 flex flex-col">
<h2 className="text-[#111928] text-sm">{title}</h2> <h2 className="text-[#111928] text-sm">{title}</h2>
<Menu.Button className="inline-flex w-full items-center justify-between gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"> <Menu.Button className="inline-flex w-full items-center justify-between gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
{checked} {checked}
<Image <Image
src={"/icons/unicorn_angle-down.svg"} src={"/icons/unicorn_angle-down.svg"}
width={12} width={12}
height={12} height={12}
alt="" alt=""
/> />
</Menu.Button> </Menu.Button>
</div> </div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="absolute right-0 z-10 mt-2 w-full origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <Menu.Items className="absolute right-0 z-10 mt-2 w-full origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1"> <div className="py-1">
{data.map((item, index) => ( {data.map((item, index) => (
<Menu.Item key={index}> <Menu.Item key={index}>
{({ active }) => ( {({ active }) => (
<a <a
onClick={() => setChecked(item)} onClick={() => setChecked(item)}
href="#" href="#"
className={classNames( className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700", active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm" "block px-4 py-2 text-sm"
)} )}
> >
{item} {item}
</a> </a>
)} )}
</Menu.Item> </Menu.Item>
))} ))}
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
); );
}; };

View File

@ -0,0 +1,24 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline";
type Props = {
title: string;
expanded: boolean;
onClick: () => void;
};
const ExpandableHeader: React.FC<Props> = ({ title, expanded, onClick }) => (
<button onClick={onClick} className="flex items-center justify-between px-2">
<h2 className="text-gray-400 font-bold text-[12px] leading-[12px] pl-1">
{title}
</h2>
<div className="mr-2">
{expanded ? (
<ChevronDownIcon width={12} height={12} color="#6B7280" />
) : (
<ChevronUpIcon width={12} height={12} color="#6B7280" />
)}
</div>
</button>
);
export default ExpandableHeader;

View File

@ -1,23 +1,21 @@
import useCreateConversation from "@/_hooks/useCreateConversation"; import useCreateConversation from "@/_hooks/useCreateConversation";
import { ProductDetailFragment } from "@/graphql"; import { Product } from "@/_models/Product";
import { useCallback } from "react";
type Props = { type Props = {
product: ProductDetailFragment; product: Product;
}; };
const GenerateImageCard: React.FC<Props> = ({ product }) => { const GenerateImageCard: React.FC<Props> = ({ product }) => {
const { name, image_url } = product; const { name, avatarUrl } = product;
const { requestCreateConvo } = useCreateConversation(); const { requestCreateConvo } = useCreateConversation();
const onClick = useCallback(() => {
requestCreateConvo(product);
}, [product]);
return ( return (
<button onClick={onClick} className="relative active:opacity-50 text-left"> <button
onClick={() => requestCreateConvo(product)}
className="relative active:opacity-50 text-left"
>
<img <img
src={image_url ?? ""} src={avatarUrl}
alt="" alt=""
className="w-full h-full rounded-[8px] bg-gray-200 group-hover:opacity-75 object-cover object-center" className="w-full h-full rounded-[8px] bg-gray-200 group-hover:opacity-75 object-cover object-center"
/> />

View File

@ -1,33 +1,27 @@
import Image from "next/image"; import { Product } from "@/_models/Product";
import GenerateImageCard from "../GenerateImageCard"; import GenerateImageCard from "../GenerateImageCard";
import { ProductDetailFragment } from "@/graphql"; import { PhotoIcon } from "@heroicons/react/24/outline";
type Props = { type Props = {
products: ProductDetailFragment[]; products: Product[];
}; };
const GenerateImageList: React.FC<Props> = ({ products }) => { const GenerateImageList: React.FC<Props> = ({ products }) => (
if (products.length === 0) { <>
return <div></div>; {products.length === 0 ? null : (
} <div className="flex items-center gap-3 mt-8 mb-2">
<PhotoIcon width={24} height={24} className="ml-6" />
return ( <span className="font-semibold text-gray-900 dark:text-white">
<div className="pb-4"> Generate Images
<div className="flex mt-4 justify-between"> </span>
<div className="gap-4 flex items-center">
<Image src={"icons/ic_image.svg"} width={20} height={20} alt="" />
<h2 className="text-gray-900 font-bold dark:text-white">
Generate Images
</h2>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-6 sm:gap-x-6 md:grid-cols-4 md:gap-8">
{products.map((item) => (
<GenerateImageCard key={item.name} product={item} />
))}
</div> </div>
)}
<div className="mt-2 mx-6 mb-6 grid grid-cols-2 gap-6 sm:gap-x-6 md:grid-cols-4 md:gap-8">
{products.map((item) => (
<GenerateImageCard key={item.name} product={item} />
))}
</div> </div>
); </>
}; );
export default GenerateImageList; export default GenerateImageList;

View File

@ -1,52 +1,49 @@
import JanWelcomeTitle from "../JanWelcomeTitle"; import JanWelcomeTitle from "../JanWelcomeTitle";
import { Product } from "@/_models/Product"; import { GetProductPromptsQuery, GetProductPromptsDocument } from "@/graphql";
import { Instance } from "mobx-state-tree"; import { useQuery } from "@apollo/client";
import { GetProductPromptsQuery, GetProductPromptsDocument } from "@/graphql"; import { Product } from "@/_models/Product";
import { useQuery } from "@apollo/client"; import { useSetAtom } from "jotai";
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
type Props = {
model: Instance<typeof Product>; type Props = {
onPromptSelected: (prompt: string) => void; product: Product;
}; };
export const GenerativeSampleContainer: React.FC<Props> = ({ const GenerativeSampleContainer: React.FC<Props> = ({ product }) => {
model, const setCurrentPrompt = useSetAtom(currentPromptAtom);
onPromptSelected, const { data } = useQuery<GetProductPromptsQuery>(GetProductPromptsDocument, {
}) => { variables: { productSlug: product.slug },
const { loading, error, data } = useQuery<GetProductPromptsQuery>( });
GetProductPromptsDocument,
{ return (
variables: { productSlug: model.id }, <div className="flex flex-col max-w-2xl flex-shrink-0 mx-auto mt-6">
} <JanWelcomeTitle
); title={product.name}
description={product.longDescription}
return ( />
<div className="flex flex-col max-w-2xl flex-shrink-0 mx-auto mt-6"> <div className="flex flex-col">
<JanWelcomeTitle <h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
title={model.name} Create now
description={model.modelDescription ?? ""} </h2>
/> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col"> {data?.prompts.map((item) => (
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5"> <button
Create now key={item.slug}
</h2> onClick={() => setCurrentPrompt(item.content ?? "")}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3"> className="w-full h-full"
{data?.prompts.map((item) => ( >
<button <img
key={item.slug} style={{ objectFit: "cover" }}
onClick={() => onPromptSelected(item.content ?? "")} className="w-full h-full rounded col-span-1 flex flex-col"
className="w-full h-full" src={item.image_url ?? ""}
> alt=""
<img />
style={{ objectFit: "cover" }} </button>
className="w-full h-full rounded col-span-1 flex flex-col" ))}
src={item.image_url ?? ""} </div>
alt="" </div>
/> </div>
</button> );
))} };
</div>
</div> export default GenerativeSampleContainer;
</div>
);
};

View File

@ -0,0 +1,22 @@
"use client";
import { showingMobilePaneAtom } from "@/_helpers/JotaiWrapper";
import { Bars3Icon } from "@heroicons/react/24/outline";
import { useSetAtom } from "jotai";
import React from "react";
const HamburgerButton: React.FC = () => {
const setShowingMobilePane = useSetAtom(showingMobilePaneAtom);
return (
<button
type="button"
className="self-end inline-flex items-center justify-center rounded-md p-1 text-gray-700 lg:hidden"
onClick={() => setShowingMobilePane(true)}
>
<span className="sr-only">Open main menu</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
);
};
export default React.memo(HamburgerButton);

View File

@ -1,61 +1,16 @@
"use client"; import React from "react";
import React, { useState } from "react"; import UserProfileDropDown from "../UserProfileDropDown";
import { Bars3Icon } from "@heroicons/react/24/outline"; import LoginButton from "../LoginButton";
import MobileMenuPane from "../MobileMenuPane"; import HamburgerButton from "../HamburgerButton";
import ConfirmSignOutModal from "../ConfirmSignOutModal";
import useSignOut from "@/_hooks/useSignOut"; const Header: React.FC = () => (
import { ThemeChanger } from "../ChangeTheme"; <header className="flex border-b-[1px] border-gray-200 p-3 dark:bg-gray-800">
import UserProfileDropDown from "../UserProfileDropDown"; <nav className="flex-1 justify-center">
import useSignIn from "@/_hooks/useSignIn"; <HamburgerButton />
import useGetCurrentUser from "@/_hooks/useGetCurrentUser"; </nav>
<LoginButton />
const Header: React.FC = () => { <UserProfileDropDown />
const { signInWithKeyCloak } = useSignIn(); </header>
const { user, loading } = useGetCurrentUser(); );
const { signOut } = useSignOut();
export default Header;
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showLogOutModal, setShowLogOutModal] = useState(false);
return (
<header
id="header"
className="text-sm bg-white border-b-[1px] border-gray-200 relative w-full py-3 px-6 dark:bg-gray-800"
>
<nav className="mx-auto flex items-center" aria-label="Global">
<div className="flex items-center flex-1 justify-center" />
<div className="hidden md:flex items-center gap-2">
<ThemeChanger />
{loading ? (
<div></div>
) : user ? (
<UserProfileDropDown
onLogOutClick={() => setShowLogOutModal(true)}
/>
) : (
<button onClick={signInWithKeyCloak}>Login</button>
)}
</div>
<div className="flex lg:hidden">
<button
type="button"
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700"
onClick={() => setMobileMenuOpen(true)}
>
<span className="sr-only">Open main menu</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</nav>
<ConfirmSignOutModal
open={showLogOutModal}
setOpen={setShowLogOutModal}
onConfirm={signOut}
/>
<MobileMenuPane open={mobileMenuOpen} setOpen={setMobileMenuOpen} />
</header>
);
};
export default Header;

View File

@ -1,27 +1,27 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
const HistoryEmpty: React.FC = () => { const HistoryEmpty: React.FC = () => {
return ( return (
<div className="flex flex-col w-full h-full items-center justify-center gap-4"> <div className="flex flex-col w-full h-full items-center justify-center gap-4">
<Image <Image
src={"/icons/chats-circle-light.svg"} src={"/icons/chats-circle-light.svg"}
width={50} width={50}
height={50} height={50}
alt="" alt=""
/> />
<p className="text-sm leading-5 text-center text-[#9CA3AF]"> <p className="text-sm leading-5 text-center text-[#9CA3AF]">
Jan allows you to use 100s of AIs on your mobile phone Jan allows you to use 100s of AIs on your mobile phone
</p> </p>
<Link <Link
href="/ai" href="/ai"
className="bg-[#1F2A37] py-[10px] px-5 gap-2 rounded-[8px] text-[14px] font-medium leading-[21px] text-white" className="bg-[#1F2A37] py-[10px] px-5 gap-2 rounded-[8px] text-[14px] font-medium leading-[21px] text-white"
> >
Explore AIs Explore AIs
</Link> </Link>
</div> </div>
); );
}; };
export default React.memo(HistoryEmpty); export default React.memo(HistoryEmpty);

View File

@ -1,87 +1,95 @@
import { AiModelType } from "@/_models/Product";
import { useStore } from "@/_models/RootStore";
import { observer } from "mobx-react-lite";
import React from "react"; import React from "react";
import JanImage from "../JanImage"; import JanImage from "../JanImage";
import { displayDate } from "@/_utils/datetime"; import { displayDate } from "@/_utils/datetime";
import {
conversationStatesAtom,
getActiveConvoIdAtom,
setActiveConvoIdAtom,
} from "@/_helpers/JotaiWrapper";
import { useAtomValue, useSetAtom } from "jotai";
import { ProductType } from "@/_models/Product";
import Image from "next/image"; import Image from "next/image";
import { Conversation } from "@/_models/Conversation";
type Props = { type Props = {
conversationId: string; conversation: Conversation;
avatarUrl: string; avatarUrl: string;
name: string; name: string;
updatedAt?: number; updatedAt?: number;
}; };
const HistoryItem: React.FC<Props> = observer( const HistoryItem: React.FC<Props> = ({
({ conversationId, avatarUrl, name, updatedAt }) => { conversation,
const { historyStore } = useStore(); avatarUrl,
const send = true; // TODO store this in mobx name,
const onClick = () => { updatedAt,
historyStore.setActiveConversationId(conversationId); }) => {
}; const conversationStates = useAtomValue(conversationStatesAtom);
const activeConvoId = useAtomValue(getActiveConvoIdAtom);
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
const isSelected = activeConvoId === conversation.id;
const conversation = historyStore.getConversationById(conversationId); const onClick = () => {
const isSelected = historyStore.activeConversationId === conversationId; if (activeConvoId !== conversation.id) {
const backgroundColor = isSelected setActiveConvoId(conversation.id);
? "bg-gray-100 dark:bg-gray-700"
: "bg-white dark:bg-gray-500";
let rightImageUrl: string | undefined;
if (conversation && conversation.isWaitingForModelResponse) {
rightImageUrl = "/icons/loading.svg";
} else if (
conversation &&
conversation.product.type === AiModelType.GenerativeArt &&
conversation.lastImageUrl &&
conversation.lastImageUrl.trim().startsWith("https://")
) {
rightImageUrl = conversation.lastImageUrl;
} }
};
return ( const backgroundColor = isSelected
<button ? "bg-gray-100 dark:bg-gray-700"
className={`flex flex-row items-center gap-[10px] rounded-lg p-2 ${backgroundColor}`} : "bg-white dark:bg-gray-500";
onClick={onClick}
> let rightImageUrl: string | undefined;
<img if (conversationStates[conversation.id]?.waitingForResponse === true) {
className="rounded-full aspect-square object-cover" rightImageUrl = "/icons/loading.svg";
src={avatarUrl} } else if (
width={36} conversation &&
alt="" conversation.product.type === ProductType.GenerativeArt &&
/> conversation.lastImageUrl &&
<div className="flex flex-col justify-between text-sm leading-[20px] w-full"> conversation.lastImageUrl.trim().startsWith("https://")
<div className="flex flex-row items-center justify-between"> ) {
<span className="text-gray-900 text-left">{name}</span> rightImageUrl = conversation.lastImageUrl;
<span className="text-[11px] leading-[13px] tracking-[-0.4px] text-gray-400"> }
{updatedAt && displayDate(updatedAt)}
return (
<button
className={`flex flex-row mx-1 items-center gap-[10px] rounded-lg p-2 ${backgroundColor} hover:bg-hover-light`}
onClick={onClick}
>
<Image
width={36}
height={36}
src={avatarUrl}
className="w-9 aspect-square rounded-full"
alt=""
/>
<div className="flex flex-col justify-between text-sm leading-[20px] w-full">
<div className="flex flex-row items-center justify-between">
<span className="text-gray-900 text-left">{name}</span>
<span className="text-[11px] leading-[13px] tracking-[-0.4px] text-gray-400">
{updatedAt && displayDate(updatedAt)}
</span>
</div>
<div className="flex items-center justify-between gap-1">
<div className="flex-1">
<span className="text-gray-400 hidden-text text-left">
{conversation?.lastTextMessage || <br className="h-5 block" />}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-1"> <>
<div className="flex-1"> {rightImageUrl != null ? (
<span className="text-gray-400 hidden-text text-left"> <JanImage
{conversation?.lastTextMessage || <br className="h-5 block" />} imageUrl={rightImageUrl ?? ""}
</span> className="rounded"
</div> width={24}
{send ? ( height={24}
<> />
{rightImageUrl != null ? ( ) : undefined}
<JanImage </>
imageUrl={rightImageUrl ?? ""}
className="rounded"
width={24}
height={24}
/>
) : undefined}
</>
) : (
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
)}
</div>
</div> </div>
</button> </div>
); </button>
} );
); };
export default HistoryItem; export default HistoryItem;

View File

@ -1,58 +1,41 @@
import HistoryItem from "../HistoryItem"; import HistoryItem from "../HistoryItem";
import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react";
import { useStore } from "@/_models/RootStore"; import ExpandableHeader from "../ExpandableHeader";
import Image from "next/image"; import { useAtomValue } from "jotai";
import { useState } from "react"; import { userConversationsAtom } from "@/_helpers/JotaiWrapper";
import useGetUserConversations from "@/_hooks/useGetUserConversations";
interface IHistoryListProps {
searchText: string; const HistoryList: React.FC = () => {
} const conversations = useAtomValue(userConversationsAtom);
const HistoryList: React.FC<IHistoryListProps> = observer((props) => { const [expand, setExpand] = useState<boolean>(true);
const { historyStore } = useStore(); const { getUserConversations } = useGetUserConversations();
const [showHistory, setShowHistory] = useState(true);
useEffect(() => {
return ( getUserConversations();
<div className="flex flex-col w-full pl-1 pt-3"> }, []);
<button
onClick={() => setShowHistory(!showHistory)} return (
className="flex items-center justify-between px-2" <div className="flex flex-col flex-grow pt-3 gap-2">
> <ExpandableHeader
<h2 className="text-[#9CA3AF] font-bold text-[12px] leading-[12px]"> title="CHAT HISTORY"
HISTORY expanded={expand}
</h2> onClick={() => setExpand(!expand)}
<Image />
className={`${showHistory ? "" : "rotate-180"}`} <div
src={"/icons/unicorn_angle-up.svg"} className={`flex flex-col gap-1 mt-1 ${!expand ? "hidden " : "block"}`}
width={24} >
height={24} {conversations.map((convo) => (
alt="" <HistoryItem
/> key={convo.id}
</button> conversation={convo}
<div className={`flex-col gap-1 ${showHistory ? "flex" : "hidden"}`}> avatarUrl={convo.product.avatarUrl}
{historyStore.conversations name={convo.product.name}
.filter( updatedAt={convo.updatedAt}
(e) => />
props.searchText === "" || ))}
e.product.name </div>
.toLowerCase() </div>
.includes(props.searchText.toLowerCase()) || );
e.product.description };
?.toLowerCase()
.includes(props.searchText.toLowerCase()) export default HistoryList;
)
.sort((n1, n2) => (n2.updatedAt || 0) - (n1.updatedAt || 0))
.map(({ id, product: aiModel, updatedAt }) => (
<HistoryItem
key={id}
conversationId={id}
avatarUrl={aiModel.avatarUrl ?? ""}
name={aiModel.name}
updatedAt={updatedAt}
/>
))}
</div>
</div>
);
});
export default HistoryList;

View File

@ -1,146 +1,23 @@
import SendButton from "../SendButton"; "use client";
import { ChangeEvent, useEffect, useState } from "react";
import { useStore } from "@/_models/RootStore";
import { AiModelType } from "@/_models/Product";
import Image from "next/image";
import { observer } from "mobx-react-lite";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
import useSignIn from "@/_hooks/useSignIn";
import { useMutation } from "@apollo/client";
import {
CreateMessageDocument,
CreateMessageMutation,
GenerateImageMutation,
GenerateImageDocument,
} from "@/graphql";
type Props = { import BasicPromptInput from "../BasicPromptInput";
prefillPrompt: string; import BasicPromptAccessories from "../BasicPromptAccessories";
}; import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper";
import { useAtomValue } from "jotai";
export const InputToolbar: React.FC<Props> = observer(({ prefillPrompt }) => { const InputToolbar: React.FC = () => {
const { historyStore } = useStore(); const showingAdvancedPrompt = useAtomValue(showingAdvancedPromptAtom);
const [text, setText] = useState(prefillPrompt);
const { user } = useGetCurrentUser();
const { signInWithKeyCloak } = useSignIn();
const [createMessageMutation] = useMutation<CreateMessageMutation>( if (showingAdvancedPrompt) {
CreateMessageDocument return <div />;
);
const [imageGenerationMutation] = useMutation<GenerateImageMutation>(
GenerateImageDocument
);
useEffect(() => {
setText(prefillPrompt);
}, [prefillPrompt]);
const handleMessageChange = (event: any) => {
setText(event.target.value);
};
const onSubmitClick = () => {
if (!user) {
signInWithKeyCloak();
return;
}
if (text.trim().length === 0) return;
historyStore.sendMessage(
createMessageMutation,
imageGenerationMutation,
text,
user.id,
user.displayName,
user.avatarUrl
);
setText("");
};
const handleKeyDown = (event: any) => {
if (event.key === "Enter") {
if (!event.shiftKey) {
event.preventDefault();
onSubmitClick();
}
}
};
let shouldDisableSubmitButton = false;
if (historyStore.getActiveConversation()?.isWaitingForModelResponse) {
shouldDisableSubmitButton = true;
} }
if (text.length === 0) {
shouldDisableSubmitButton = true;
}
const onAdvancedPrompt = () => {
historyStore.toggleAdvancedPrompt();
};
const handleResize = (event: ChangeEvent<HTMLTextAreaElement>) => {
event.target.style.height = "auto";
event.target.style.height = event.target.scrollHeight + "px";
};
const shouldShowAdvancedPrompt =
historyStore.getActiveConversation()?.product?.type ===
AiModelType.ControlNet ?? false;
return ( return (
<div <div className="mx-3 mb-3 flex-none overflow-hidden shadow-sm ring-1 ring-inset ring-gray-300 rounded-lg dark:bg-gray-800">
className={`${ <BasicPromptInput />
historyStore.showAdvancedPrompt ? "hidden" : "block" <BasicPromptAccessories />
} mb-3 flex-none overflow-hidden w-full shadow-sm ring-1 ring-inset ring-gray-300 rounded-lg dark:bg-gray-800`}
>
<div className="overflow-hidden">
<label htmlFor="comment" className="sr-only">
Add your comment
</label>
<textarea
onKeyDown={handleKeyDown}
value={text}
onChange={handleMessageChange}
onInput={handleResize}
rows={2}
name="comment"
id="comment"
className="block w-full scroll resize-none border-0 bg-transparent py-1.5 text-gray-900 transition-height duration-200 placeholder:text-gray-400 sm:text-sm sm:leading-6 dark:text-white"
placeholder="Add your comment..."
/>
</div>
<div
style={{
backgroundColor: "#F8F8F8",
borderWidth: 1,
borderColor: "#D1D5DB",
}}
className="flex justify-between py-2 pl-3 pr-2 rounded-b-lg"
>
{shouldShowAdvancedPrompt && (
<button
onClick={onAdvancedPrompt}
className="flex items-center gap-1 py-[1px]"
>
<Image
src={"/icons/ic_setting.svg"}
width={20}
height={20}
alt=""
/>
<span className="text-sm leading-5 text-gray-600">Advanced</span>
</button>
)}
<div className="flex justify-end items-center space-x-1 w-full pr-3" />
<div className="flex-shrink-0">
{!shouldShowAdvancedPrompt && (
<SendButton
onClick={onSubmitClick}
disabled={shouldDisableSubmitButton}
/>
)}
</div>
</div>
</div> </div>
); );
}); };
export default InputToolbar;

View File

@ -0,0 +1,19 @@
import { setActiveConvoIdAtom } from "@/_helpers/JotaiWrapper";
import { useSetAtom } from "jotai";
import Image from "next/image";
import React from "react";
const JanLogo: React.FC = () => {
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
return (
<button
className="p-3 flex gap-[2px] items-center"
onClick={() => setActiveConvoId(undefined)}
>
<Image src={"/icons/app_icon.svg"} width={28} height={28} alt="" />
<Image src={"/icons/Jan.svg"} width={27} height={12} alt="" />
</button>
);
};
export default React.memo(JanLogo);

View File

@ -1,20 +1,20 @@
import React from "react"; import React from "react";
import Image from "next/image"; import Image from "next/image";
type Props = { type Props = {
title: string; title: string;
description: string; description: string;
}; };
const JanWelcomeTitle: React.FC<Props> = ({ title, description }) => ( const JanWelcomeTitle: React.FC<Props> = ({ title, description }) => (
<div className="flex items-center flex-col gap-3"> <div className="flex items-center flex-col gap-3">
<h2 className="text-[22px] leading-7 font-bold">{title}</h2> <h2 className="text-[22px] leading-7 font-bold">{title}</h2>
<span className="flex items-center text-xs leading-[18px]"> <span className="flex items-center text-xs leading-[18px]">
Operated by Operated by
<Image src={"/icons/ico_logo.svg"} width={42} height={22} alt="" /> <Image src={"/icons/ico_logo.svg"} width={42} height={22} alt="" />
</span> </span>
<span className="text-sm text-center font-normal">{description}</span> <span className="text-sm text-center font-normal">{description}</span>
</div> </div>
); );
export default React.memo(JanWelcomeTitle); export default React.memo(JanWelcomeTitle);

View File

@ -0,0 +1,24 @@
"use client";
import { useAtomValue } from "jotai";
import AdvancedPrompt from "../AdvancedPrompt";
import CompactSideBar from "../CompactSideBar";
import LeftSidebar from "../LeftSidebar";
import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper";
const LeftContainer: React.FC = () => {
const isShowingAdvPrompt = useAtomValue(showingAdvancedPromptAtom);
if (isShowingAdvPrompt) {
return (
<div className="flex h-screen">
<CompactSideBar />
<AdvancedPrompt />
</div>
);
}
return <LeftSidebar />;
};
export default LeftContainer;

View File

@ -0,0 +1,20 @@
import React from "react";
import SearchBar from "../SearchBar";
import ShortcutList from "../ShortcutList";
import HistoryList from "../HistoryList";
import DiscordContainer from "../DiscordContainer";
import JanLogo from "../JanLogo";
const LeftSidebar: React.FC = () => (
<div className="hidden h-screen lg:flex flex-col lg:inset-y-0 lg:w-72 lg:flex-col flex-shrink-0 overflow-hidden border-r border-gray-200 dark:bg-gray-800">
<JanLogo />
<div className="flex flex-col flex-1 gap-3 overflow-x-hidden">
<SearchBar />
<ShortcutList />
<HistoryList />
</div>
<DiscordContainer />
</div>
);
export default LeftSidebar;

View File

@ -0,0 +1,27 @@
"use client";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
import useSignIn from "@/_hooks/useSignIn";
const LoginButton: React.FC = () => {
const { signInWithKeyCloak } = useSignIn();
const { user, loading } = useGetCurrentUser();
if (loading || user) {
return <div />;
}
return (
<div className="hidden lg:block">
<button
onClick={signInWithKeyCloak}
type="button"
className="rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Login
</button>
</div>
);
};
export default LoginButton;

View File

@ -0,0 +1,13 @@
import ChatBody from "../ChatBody";
import InputToolbar from "../InputToolbar";
import MainChatHeader from "../MainChatHeader";
const MainChat: React.FC = () => (
<div className="flex flex-col h-full w-full">
<MainChatHeader />
<ChatBody />
<InputToolbar />
</div>
);
export default MainChat;

View File

@ -0,0 +1,11 @@
import ModelMenu from "../ModelMenu";
import UserToolbar from "../UserToolbar";
const MainChatHeader: React.FC = () => (
<div className="flex w-full px-3 justify-between py-1 border-b border-gray-200 shadow-sm dark:bg-gray-950">
<UserToolbar />
<ModelMenu />
</div>
);
export default MainChatHeader;

View File

@ -1,23 +1,21 @@
import AdvancedPromptText from "../AdvancedPromptText"; import AdvancedPromptText from "../AdvancedPromptText";
import AdvancedPromptImageUpload from "../AdvancedPromptImageUpload"; import AdvancedPromptImageUpload from "../AdvancedPromptImageUpload";
import AdvancedPromptResolution from "../AdvancedPromptResolution"; import AdvancedPromptResolution from "../AdvancedPromptResolution";
import AdvancedPromptGenerationParams from "../AdvancedPromptGenerationParams"; import AdvancedPromptGenerationParams from "../AdvancedPromptGenerationParams";
import { FieldValues, UseFormRegister } from "react-hook-form"; import { FieldValues, UseFormRegister } from "react-hook-form";
type Props = { type Props = {
register: UseFormRegister<FieldValues>; register: UseFormRegister<FieldValues>;
}; };
export const MenuAdvancedPrompt: React.FC<Props> = ({ register }) => { export const MenuAdvancedPrompt: React.FC<Props> = ({ register }) => (
return ( <div className="flex flex-col flex-1 p-3 gap-[10px] overflow-x-hidden scroll">
<div className="flex flex-col"> <AdvancedPromptText register={register} />
<AdvancedPromptText register={register} /> <hr className="my-5" />
<hr className="my-5" /> <AdvancedPromptImageUpload register={register} />
<AdvancedPromptImageUpload register={register} /> <hr className="my-5" />
<hr className="my-5" /> <AdvancedPromptResolution />
<AdvancedPromptResolution /> <hr className="my-5" />
<hr className="my-5" /> <AdvancedPromptGenerationParams />
<AdvancedPromptGenerationParams /> </div>
</div> );
);
};

View File

@ -1,56 +1,55 @@
import Link from "next/link"; import Link from "next/link";
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
import { Fragment } from "react"; import { Fragment } from "react";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser"; import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
import { useSetAtom } from "jotai";
type Props = { import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper";
onLogOutClick: () => void;
}; export const MenuHeader: React.FC = () => {
const setShowConfirmSignOutModal = useSetAtom(showConfirmSignOutModalAtom);
export const MenuHeader: React.FC<Props> = ({ onLogOutClick }) => { const { user } = useGetCurrentUser();
const { user } = useGetCurrentUser();
if (!user) {
if (!user) { return <div></div>;
return <div></div>; }
}
return (
return ( <Transition
<Transition as={Fragment}
as={Fragment} enter="transition ease-out duration-200"
enter="transition ease-out duration-200" enterFrom="opacity-0 translate-y-1"
enterFrom="opacity-0 translate-y-1" enterTo="opacity-100 translate-y-0"
enterTo="opacity-100 translate-y-0" leave="transition ease-in duration-150"
leave="transition ease-in duration-150" leaveFrom="opacity-100 translate-y-0"
leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1"
leaveTo="opacity-0 translate-y-1" >
> <Popover.Panel className="absolute shadow-profile -right-2 top-full z-10 mt-3 w-[224px] overflow-hidden rounded-[6px] bg-white shadow-lg ring-1 ring-gray-200">
<Popover.Panel className="absolute shadow-profile -right-2 top-full z-10 mt-3 w-[224px] overflow-hidden rounded-[6px] bg-white shadow-lg ring-1 ring-gray-200"> <div className="py-3 px-4 gap-2 flex flex-col">
<div className="py-3 px-4 gap-2 flex flex-col"> <h2 className="text-[20px] leading-[25px] tracking-[-0.4px] font-bold text-[#111928]">
<h2 className="text-[20px] leading-[25px] tracking-[-0.4px] font-bold text-[#111928]"> {user.displayName}
{user.displayName} </h2>
</h2> <span className="text-[#6B7280] leading-[17.5px] text-sm">
<span className="text-[#6B7280] leading-[17.5px] text-sm"> {user.email}
{user.email} </span>
</span> </div>
</div> <hr />
<hr /> <button
<button onClick={() => setShowConfirmSignOutModal(true)}
onClick={onLogOutClick} className="px-4 py-3 text-sm w-full text-left text-gray-700"
className="px-4 py-3 text-sm w-full text-left text-gray-700" >
> Sign Out
Sign Out </button>
</button> <hr />
<hr /> <div className="flex gap-2 px-4 py-2 justify-center items-center">
<div className="flex gap-2 px-4 py-2 justify-center items-center"> <Link href="/privacy">
<Link href="/privacy"> <span className="text-[#6B7280] text-xs">Privacy</span>
<span className="text-[#6B7280] text-xs">Privacy</span> </Link>
</Link> <div className="w-1 h-1 bg-[#D9D9D9] rounded-lg" />
<div className="w-1 h-1 bg-[#D9D9D9] rounded-lg" /> <Link href="/support">
<Link href="/support"> <span className="text-[#6B7280] text-xs">Support</span>
<span className="text-[#6B7280] text-xs">Support</span> </Link>
</Link> </div>
</div> </Popover.Panel>
</Popover.Panel> </Transition>
</Transition> );
); };
};

View File

@ -21,7 +21,7 @@ const MobileDownload = () => {
{/** Buttons */} {/** Buttons */}
<div className="flex w-full mt-4 justify-between"> <div className="flex w-full mt-4 justify-between">
<a <a
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_IOS || "#"} href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_IOS || ""}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-[48%]" className="w-[48%]"
@ -42,7 +42,7 @@ const MobileDownload = () => {
</a> </a>
<a <a
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_ANDROID || "#"} href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_ANDROID || ""}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-[48%]" className="w-[48%]"

View File

@ -1,52 +1,60 @@
import React from "react"; import React, { useRef } from "react";
import { Dialog } from "@headlessui/react"; import { Dialog } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import Image from "next/image"; import Image from "next/image";
import { showingMobilePaneAtom } from "@/_helpers/JotaiWrapper";
import { useAtom } from "jotai";
type Props = { const MobileMenuPane: React.FC = () => {
open: boolean; const [show, setShow] = useAtom(showingMobilePaneAtom);
setOpen: (open: boolean) => void; let loginRef = useRef(null);
};
const MobileMenuPane: React.FC<Props> = ({ open, setOpen }) => ( return (
<Dialog as="div" className="md:hidden" open={open} onClose={setOpen}> <Dialog
<div className="fixed inset-0 z-10" /> as="div"
<Dialog.Panel className="fixed inset-y-0 right-0 z-10 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10"> open={show}
<div className="flex items-center justify-between"> initialFocus={loginRef}
<a href="#" className="-m-1.5 p-1.5"> onClose={() => setShow(false)}
<span className="sr-only">Your Company</span> >
<Image <div className="fixed inset-0 z-10" />
className="h-8 w-auto" <Dialog.Panel className="fixed inset-y-0 right-0 z-10 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10">
width={32} <div className="flex items-center justify-between">
height={32} <a href="#" className="-m-1.5 p-1.5">
src="/icons/app_icon.svg" <span className="sr-only">Your Company</span>
alt="" <Image
/> className="h-8 w-auto"
</a> width={32}
<button height={32}
type="button" src="/icons/app_icon.svg"
className="-m-2.5 rounded-md p-2.5 text-gray-700" alt=""
onClick={() => setOpen(false)} />
> </a>
<span className="sr-only">Close menu</span> <button
<XMarkIcon className="h-6 w-6" aria-hidden="true" /> type="button"
</button> className="-m-2.5 rounded-md p-2.5 text-gray-700"
</div> onClick={() => setShow(false)}
<div className="mt-6 flow-root"> >
<div className="-my-6 divide-y divide-gray-500/10"> <span className="sr-only">Close menu</span>
<div className="space-y-2 py-6"/> <XMarkIcon className="h-6 w-6" aria-hidden="true" />
<div className="py-6"> </button>
<a </div>
href="#" <div className="mt-6 flow-root">
className="-mx-3 block rounded-lg px-3 py-2.5 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50" <div className="-my-6 divide-y divide-gray-500/10">
> <div className="space-y-2 py-6" />
Log in <div className="py-6">
</a> <a
ref={loginRef}
href="#"
className="-mx-3 block rounded-lg px-3 py-2.5 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
Log in
</a>
</div>
</div> </div>
</div> </div>
</div> </Dialog.Panel>
</Dialog.Panel> </Dialog>
</Dialog> );
); };
export default MobileMenuPane; export default MobileMenuPane;

View File

@ -1,36 +1,9 @@
import { FC, useRef } from "react"; import OverviewPane from "../OverviewPane";
import OverviewPane from "../OverviewPane";
import { observer } from "mobx-react-lite"; const ModelDetailSideBar: React.FC = () => (
import { useStore } from "@/_models/RootStore"; <div className="flex w-[473px] h-full border-l-[1px] border-[#E5E7EB]">
import { Draggable } from "../Draggable"; <OverviewPane />
</div>
type Props = { );
onPromptClick?: (prompt: string) => void;
}; export default ModelDetailSideBar;
export const ModelDetailSideBar: FC<Props> = observer(({ onPromptClick }) => {
const ref = useRef<HTMLDivElement>(null);
const { historyStore } = useStore();
const conversation = useStore().historyStore.getActiveConversation();
return (
<div
style={historyStore.showModelDetail ? { width: "473px" } : {}}
ref={ref}
className={`${
historyStore.showModelDetail ? "w-[473px]" : "hidden"
} flex flex-col gap-3 h-full p-3 relative pb-3 border-l-[1px] border-[#E5E7EB]`}
>
<Draggable targetRef={ref} />
<div className="flex-col h-full gap-3 flex flex-1">
<OverviewPane
slug={conversation?.product.id ?? ""}
onPromptClick={onPromptClick}
description={conversation?.product.description}
technicalURL={conversation?.product.modelUrl}
technicalVersion={conversation?.product.modelVersion}
/>
</div>
</div>
);
});

View File

@ -1,42 +1,42 @@
import Image from "next/image"; import Image from "next/image";
import ModelInfoItem from "../ModelInfoItem"; import ModelInfoItem from "../ModelInfoItem";
import React from "react"; import React from "react";
type Props = { type Props = {
modelName: string; modelName: string;
inferenceTime: string; inferenceTime: string;
hardware: string; hardware: string;
pricing: string; pricing: string;
}; };
const ModelInfo: React.FC<Props> = ({ const ModelInfo: React.FC<Props> = ({
modelName, modelName,
inferenceTime, inferenceTime,
hardware, hardware,
pricing, pricing,
}) => ( }) => (
<div className="flex flex-col rounded-lg border border-gray-200 p-3 gap-3"> <div className="flex flex-col rounded-lg border border-gray-200 p-3 gap-3">
<h2 className="font-semibold text-sm text-gray-900 dark:text-white"> <h2 className="font-semibold text-sm text-gray-900 dark:text-white">
{modelName} is available via Jan API {modelName} is available via Jan API
</h2> </h2>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<ModelInfoItem description={inferenceTime} name="Inference Time" /> <ModelInfoItem description={inferenceTime} name="Inference Time" />
<ModelInfoItem description={hardware} name="Hardware" /> <ModelInfoItem description={hardware} name="Hardware" />
</div> </div>
<hr /> <hr />
<div className="flex justify-between items-center "> <div className="flex justify-between items-center ">
<div className="flex flex-col"> <div className="flex flex-col">
<h2 className="text-xl tracking-[-0.4px] font-semibold">{pricing}</h2> <h2 className="text-xl tracking-[-0.4px] font-semibold">{pricing}</h2>
<span className="text-xs leading-[18px] text-[#6B7280]"> <span className="text-xs leading-[18px] text-[#6B7280]">
Average Cost / Call Average Cost / Call
</span> </span>
</div> </div>
<button className="px-3 py-2 bg-[#1F2A37] flex gap-2 items-center rounded-lg"> <button className="px-3 py-2 bg-[#1F2A37] flex gap-2 items-center rounded-lg">
<Image src={"/icons/code.svg"} width={16} height={17} alt="" /> <Image src={"/icons/code.svg"} width={16} height={17} alt="" />
<span className="text-white text-sm font-medium">Get API Key</span> <span className="text-white text-sm font-medium">Get API Key</span>
</button> </button>
</div> </div>
</div> </div>
); );
export default React.memo(ModelInfo); export default React.memo(ModelInfo);

View File

@ -1,15 +1,15 @@
import React from "react"; import React from "react";
type Props = { type Props = {
name: string; name: string;
description: string; description: string;
}; };
const ModelInfoItem: React.FC<Props> = ({ description, name }) => ( const ModelInfoItem: React.FC<Props> = ({ description, name }) => (
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<span className="text-gray-500 font-normal text-sm">{name}</span> <span className="text-gray-500 font-normal text-sm">{name}</span>
<span className="font-normal text-sm">{description}</span> <span className="font-normal text-sm">{description}</span>
</div> </div>
); );
export default React.memo(ModelInfoItem); export default React.memo(ModelInfoItem);

View File

@ -1,44 +1,46 @@
import Image from "next/image"; "use client";
import { useStore } from "@/_models/RootStore";
import { useCallback } from "react"; import Image from "next/image";
import { observer } from "mobx-react-lite"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
type Props = { currentProductAtom,
onDeleteClick: () => void; showConfirmDeleteConversationModalAtom,
onCreateConvClick: () => void; showingProductDetailAtom,
}; } from "@/_helpers/JotaiWrapper";
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
const ModelMenu: React.FC<Props> = observer( import useCreateConversation from "@/_hooks/useCreateConversation";
({ onDeleteClick, onCreateConvClick }) => {
const { historyStore } = useStore(); const ModelMenu: React.FC = () => {
const currentProduct = useAtomValue(currentProductAtom);
const onModelInfoClick = useCallback(() => { const [active, setActive] = useAtom(showingProductDetailAtom);
historyStore.toggleModelDetail(); const { requestCreateConvo } = useCreateConversation();
}, []); const setShowConfirmDeleteConversationModal = useSetAtom(
showConfirmDeleteConversationModalAtom
return ( );
<div className="flex items-center gap-3">
<button onClick={onCreateConvClick}> const onCreateConvoClick = () => {
<Image src="/icons/unicorn_plus.svg" width={24} height={24} alt="" /> if (!currentProduct) return;
</button> requestCreateConvo(currentProduct, true);
<button onClick={onDeleteClick}> };
<Image src="/icons/unicorn_trash.svg" width={24} height={24} alt="" />
</button> return (
<button onClick={onModelInfoClick}> <div className="flex items-center gap-3">
<Image <button onClick={() => onCreateConvoClick()}>
src={ <PlusIcon width={24} height={24} color="#9CA3AF" />
historyStore.showModelDetail </button>
? "/icons/ic_sidebar_fill.svg" <button onClick={() => setShowConfirmDeleteConversationModal(true)}>
: "/icons/ic_sidebar.svg" <TrashIcon width={24} height={24} color="#9CA3AF" />
} </button>
width={24} <button onClick={() => setActive(!active)}>
height={24} <Image
alt="" src={active ? "/icons/ic_sidebar_fill.svg" : "/icons/ic_sidebar.svg"}
/> width={24}
</button> height={24}
</div> alt=""
); />
} </button>
); </div>
);
export default ModelMenu; };
export default ModelMenu;

View File

@ -1,54 +0,0 @@
import React from "react";
import Slider from "../Slider";
import ConversationalList from "../ConversationalList";
import GenerateImageList from "../GenerateImageList";
import { GetProductsQuery, GetProductsDocument } from "@/graphql";
import { useQuery } from "@apollo/client";
import Image from "next/image";
const NewChatBlankState: React.FC = () => {
// This can be achieved by separating queries using GetProductsByCollectionSlugQuery
const { loading, data } = useQuery<GetProductsQuery>(GetProductsDocument, {
variables: { slug: "conversational" },
});
const featured = [...(data?.products ?? [])]
.sort(() => 0.5 - Math.random())
.slice(0, 3);
const conversational =
data?.products.filter((e) =>
e.product_collections.some((c) =>
c.collections.some((s) => s.slug == "conversational")
)
) ?? [];
const generativeArts =
data?.products.filter((e) =>
e.product_collections.some((c) =>
c.collections.some((s) => s.slug == "text-to-image")
)
) ?? [];
if (loading) {
return (
<div className="w-full flex flex-row justify-center items-center">
<Image src="/icons/loading.svg" width={32} height={32} alt="loading" />
</div>
);
}
if (!data || data.products.length === 0) {
return <div></div>;
}
return (
<div className="bg-gray-100 px-6 pt-8 w-full h-full overflow-y-scroll scroll">
<Slider products={featured} />
<ConversationalList products={conversational} />
<GenerateImageList products={generativeArts} />
</div>
);
};
export default NewChatBlankState;

View File

@ -1,94 +1,58 @@
import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql"; "use client";
import { useQuery } from "@apollo/client";
import { useLayoutEffect, useRef, useState } from "react";
type Props = { import { useAtomValue } from "jotai";
slug: string; import TryItYourself from "./TryItYourself";
description?: string | null; import React from "react";
technicalVersion?: string | null; import { currentProductAtom } from "@/_helpers/JotaiWrapper";
technicalURL?: string | null;
onPromptClick?: (prompt: string) => void;
inAIModel?: number;
};
const OverviewPane: React.FC<Props> = ({ const OverviewPane: React.FC = () => {
slug, const product = useAtomValue(currentProductAtom);
description,
technicalVersion,
technicalURL,
onPromptClick,
inAIModel,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [read, setRead] = useState<boolean>(true);
const [height, setHeight] = useState<number>(0);
const { loading, error, data } = useQuery<GetProductPromptsQuery>(
GetProductPromptsDocument,
{
variables: { productSlug: slug },
}
);
useLayoutEffect(() => {
if (!ref.current) return;
setHeight(ref.current?.offsetHeight);
}, [read]);
return ( return (
<div <div className="scroll overflow-y-auto">
className="w-full flex flex-auto flex-col gap-6 overflow-x-hidden scroll" <div className="flex flex-col flex-grow gap-6 m-3">
ref={ref} <AboutProductItem
style={!inAIModel ? { height: `${height}px` } : { height: "100%" }} title={"About this AI"}
> value={product?.description ?? ""}
<div className="flex flex-col gap-2 items-start"> />
<h2 className="text-black font-bold">About this AI</h2> <SmallItem title={"Model Version"} value={product?.version ?? ""} />
<p className={`text-[#6B7280] ${read ? "hidden-text-model" : ""}`}> <div className="flex flex-col">
{description}
</p>
<button
onClick={() => setRead(!read)}
className="text-[#1F2A37] font-bold"
>
{read ? "read more" : "read less"}
</button>
</div>
<div className="flex flex-col gap-4 tracking-[-0.4px] leading-[22px] text-base">
<div className="flex flex-col gap-1">
<span className="text-[#6B7280] ">Model Version</span>
<span className="font-semibold">{technicalVersion}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[#6B7280]">Model URL</span> <span className="text-[#6B7280]">Model URL</span>
<a <a
className="text-[#1C64F2] break-all pr-10" className="text-[#1C64F2]"
href={technicalURL || "#"} href={product?.modelUrl ?? "#"}
target="_blank_" target="_blank_"
> >
{technicalURL} {product?.modelUrl}
</a> </a>
</div> </div>
</div> <TryItYourself />
<div className="flex flex-col gap-4 tracking-[-0.4px] leading-[22px] text-base">
<h2 className="font-bold">Try it yourself</h2>
<ul className="border-[1px] border-[#D1D5DB] rounded-[12px]">
{data?.prompts.map((prompt, index) => {
const showBorder = index !== data?.prompts.length - 1;
return (
<button
onClick={() => onPromptClick?.(prompt.content ?? "")}
key={prompt.slug}
className={`text-sm text-gray-500 leading-[20px] flex gap-[10px] border-b-[${
showBorder ? "1" : "0"
}px] border-[#E5E7EB] hover:text-blue-400 text-left p-3 w-full`}
>
{prompt.content}
</button>
);
})}
</ul>
</div> </div>
</div> </div>
); );
}; };
export default OverviewPane; export default OverviewPane;
type Props = {
title: string;
value: string;
};
const AboutProductItem: React.FC<Props> = ({ title, value }) => {
return (
<div className="flex flex-col items-start">
<h2 className="text-black font-bold">{title}</h2>
<p className="text-[#6B7280]">{value}</p>
</div>
);
};
const SmallItem: React.FC<Props> = ({ title, value }) => {
return (
<div className="flex flex-col">
<span className="text-[#6B7280] ">{title}</span>
<span className="font-semibold">{value}</span>
</div>
);
};

View File

@ -0,0 +1,25 @@
import React from "react";
type Props = {
title: string;
onClick: () => void;
fullWidth?: boolean;
};
const PrimaryButton: React.FC<Props> = ({
title,
onClick,
fullWidth = false,
}) => (
<button
onClick={onClick}
type="button"
className={`rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-50 ${
fullWidth ? "flex-1 " : ""
}}`}
>
{title}
</button>
);
export default PrimaryButton;

View File

@ -0,0 +1,29 @@
import React from "react";
import Slider from "../Slider";
import ConversationalList from "../ConversationalList";
import GenerateImageList from "../GenerateImageList";
import Image from "next/image";
import useGetProducts from "@/_hooks/useGetProducts";
const ProductOverview: React.FC = () => {
const { loading, featured, conversational, generativeArts } =
useGetProducts();
if (loading) {
return (
<div className="w-full flex flex-grow flex-row justify-center items-center">
<Image src="/icons/loading.svg" width={32} height={32} alt="loading" />
</div>
);
}
return (
<div className="bg-gray-100 overflow-y-auto flex-grow scroll">
<Slider products={featured} />
<ConversationalList products={conversational} />
<GenerateImageList products={generativeArts} />
</div>
);
};
export default ProductOverview;

View File

@ -0,0 +1,14 @@
import ChatContainer from "../ChatContainer";
import Header from "../Header";
import MainChat from "../MainChat";
const RightContainer = () => (
<div className="flex flex-col flex-1 h-screen">
<Header />
<ChatContainer>
<MainChat />
</ChatContainer>
</div>
);
export default RightContainer;

View File

@ -1,46 +1,44 @@
import { Instance } from "mobx-state-tree"; import JanWelcomeTitle from "../JanWelcomeTitle";
import { Product } from "@/_models/Product"; import { useQuery } from "@apollo/client";
import JanWelcomeTitle from "../JanWelcomeTitle"; import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql";
import { useQuery } from "@apollo/client"; import { Product } from "@/_models/Product";
import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql"; import { useSetAtom } from "jotai";
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
type Props = {
model: Instance<typeof Product>; type Props = {
onPromptSelected: (prompt: string) => void; product: Product;
}; };
const SampleLlmContainer: React.FC<Props> = ({ model, onPromptSelected }) => { const SampleLlmContainer: React.FC<Props> = ({ product }) => {
const { loading, error, data } = useQuery<GetProductPromptsQuery>( const setCurrentPrompt = useSetAtom(currentPromptAtom);
GetProductPromptsDocument, const { data } = useQuery<GetProductPromptsQuery>(GetProductPromptsDocument, {
{ variables: { productSlug: product.slug },
variables: { productSlug: model.id }, });
}
); return (
<div className="flex flex-col max-w-sm flex-shrink-0 gap-9 items-center pt-6 mx-auto">
return ( <JanWelcomeTitle
<div className="flex flex-col max-w-sm flex-shrink-0 gap-9 items-center pt-6 mx-auto"> title={product.name}
<JanWelcomeTitle description={product.description ?? ""}
title={model.name} />
description={model.description ?? ""} <div className="flex flex-col">
/> <h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
<div className="flex flex-col"> Try now
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5"> </h2>
Try now <div className="flex flex-col">
</h2> {data?.prompts.map((item) => (
<div className="flex flex-col"> <button
{data?.prompts.map((item) => ( onClick={() => setCurrentPrompt(item.content ?? "")}
<button key={item.slug}
onClick={() => onPromptSelected(item.content ?? "")} className="rounded p-2 hover:bg-[#0000000F] text-xs leading-[18px] text-gray-500 text-left"
key={item.slug} >
className="rounded p-2 hover:bg-[#0000000F] text-xs leading-[18px] text-gray-500 text-left" <span className="line-clamp-3">{item.content}</span>
> </button>
<span className="line-clamp-3">{item.content}</span> ))}
</button> </div>
))} </div>
</div> </div>
</div> );
</div> };
);
}; export default SampleLlmContainer;
export default SampleLlmContainer;

View File

@ -1,24 +1,30 @@
import Image from "next/image"; import { searchAtom } from "@/_helpers/JotaiWrapper";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
interface ISearchBarProps { import { useSetAtom } from "jotai";
onTextChanged: (text: string) => void;
} const SearchBar: React.FC = () => {
const SearchBar: React.FC<ISearchBarProps> = (props) => { const setText = useSetAtom(searchAtom);
return (
<div className="relative mt-3 flex items-center w-full"> return (
<div className="absolute top-0 left-2 h-full flex items-center"> <div className="relative mx-3 mt-3 flex items-center">
<Image src={"/icons/search.svg"} width={16} height={16} alt="" /> <div className="absolute top-0 left-2 h-full flex items-center">
</div> <MagnifyingGlassIcon
<input width={16}
type="text" height={16}
name="search" color="#3C3C43"
id="search" opacity={0.6}
placeholder="Search (⌘K)" />
onChange={(e) => props.onTextChanged(e.target.value)} </div>
className="block w-full rounded-md border-0 py-1.5 pl-8 pr-14 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" <input
/> type="text"
</div> name="search"
); id="search"
}; placeholder="Search (⌘K)"
onChange={(e) => setText(e.target.value)}
export default SearchBar; className="block w-full rounded-md border-0 py-1.5 pl-8 pr-14 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
);
};
export default SearchBar;

View File

@ -0,0 +1,18 @@
type Props = {
title: string;
onClick: () => void;
disabled?: boolean;
};
const SecondaryButton: React.FC<Props> = ({ title, onClick, disabled }) => (
<button
disabled={disabled}
type="button"
onClick={onClick}
className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
{title}
</button>
);
export default SecondaryButton;

View File

@ -1,11 +1,19 @@
import {
currentConvoStateAtom,
currentPromptAtom,
} from "@/_helpers/JotaiWrapper";
import useSendChatMessage from "@/_hooks/useSendChatMessage";
import { useAtomValue } from "jotai";
import Image from "next/image"; import Image from "next/image";
type Props = { const SendButton: React.FC = () => {
onClick: () => void; const currentPrompt = useAtomValue(currentPromptAtom);
disabled?: boolean; const currentConvoState = useAtomValue(currentConvoStateAtom);
}; const { sendChatMessage } = useSendChatMessage();
const isWaitingForResponse = currentConvoState?.waitingForResponse ?? false;
const disabled = currentPrompt.trim().length === 0 || isWaitingForResponse;
const SendButton: React.FC<Props> = ({ onClick, disabled = false }) => {
const enabledStyle = { const enabledStyle = {
backgroundColor: "#FACA15", backgroundColor: "#FACA15",
}; };
@ -16,7 +24,7 @@ const SendButton: React.FC<Props> = ({ onClick, disabled = false }) => {
return ( return (
<button <button
onClick={onClick} onClick={sendChatMessage}
style={disabled ? disabledStyle : enabledStyle} style={disabled ? disabledStyle : enabledStyle}
type="submit" type="submit"
className="p-2 gap-[10px] inline-flex items-center rounded-[12px] text-sm font-semibold shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" className="p-2 gap-[10px] inline-flex items-center rounded-[12px] text-sm font-semibold shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"

View File

@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import useCreateConversation from "@/_hooks/useCreateConversation"; import useCreateConversation from "@/_hooks/useCreateConversation";
import { ProductDetailFragment } from "@/graphql"; import Image from "next/image";
import { Product } from "@/_models/Product";
type Props = { type Props = {
product: ProductDetailFragment; product: Product;
}; };
const ShortcutItem: React.FC<Props> = ({ product }) => { const ShortcutItem: React.FC<Props> = ({ product }) => {
@ -14,17 +15,22 @@ const ShortcutItem: React.FC<Props> = ({ product }) => {
}; };
return ( return (
<button className="flex items-center gap-2" onClick={onClickHandler}> <button
{product.image_url && ( className="flex items-center gap-2 mx-1 p-2"
<img onClick={onClickHandler}
src={product.image_url} >
{product.avatarUrl && (
<Image
width={36}
height={36}
src={product.avatarUrl}
className="w-9 aspect-square rounded-full" className="w-9 aspect-square rounded-full"
alt="" alt=""
/> />
)} )}
<div className="flex flex-col text-sm leading-[20px]"> <span className="text-gray-900 dark:text-white font-normal text-sm">
<span className="text-[#111928] dark:text-white">{product.name}</span> {product.name}
</div> </span>
</button> </button>
); );
}; };

View File

@ -1,42 +1,52 @@
import Image from "next/image"; "use client";
import React from "react";
import React, { useEffect, useState } from "react";
import ShortcutItem from "../ShortcutItem"; import ShortcutItem from "../ShortcutItem";
import { observer } from "mobx-react-lite"; import { GetProductsDocument, GetProductsQuery } from "@/graphql";
import { ProductDetailFragment } from "@/graphql"; import ExpandableHeader from "../ExpandableHeader";
import { useQuery } from "@apollo/client";
import { useAtomValue } from "jotai";
import { searchAtom } from "@/_helpers/JotaiWrapper";
import { Product, toProduct } from "@/_models/Product";
type Props = { const ShortcutList: React.FC = () => {
products: ProductDetailFragment[]; const searchText = useAtomValue(searchAtom);
}; const { data } = useQuery<GetProductsQuery>(GetProductsDocument);
const [expand, setExpand] = useState<boolean>(true);
const [featuredProducts, setFeaturedProducts] = useState<Product[]>([]);
const ShortcutList: React.FC<Props> = observer(({ products }) => { useEffect(() => {
const [expand, setExpand] = React.useState<boolean>(true); if (data?.products) {
const products: Product[] = data.products.map((p) => toProduct(p));
setFeaturedProducts(
[...(products || [])]
.sort(() => 0.5 - Math.random())
.slice(0, 3)
.filter(
(e) =>
searchText === "" ||
e.name.toLowerCase().includes(searchText.toLowerCase())
) || []
);
}
}, [data?.products, searchText]);
return ( return (
<div className="flex flex-col w-full px-3 pt-3"> <div className="flex flex-col mt-6 gap-2">
<button <ExpandableHeader
title="START A NEW CHAT"
expanded={expand}
onClick={() => setExpand(!expand)} onClick={() => setExpand(!expand)}
className="flex justify-between items-center" />
> {expand ? (
<h2 className="text-[#9CA3AF] font-bold text-xs leading-[12px]"> <div className="flex flex-row mx-1 items-center rounded-lg hover:bg-hover-light">
SHORTCUTS {featuredProducts.map((product) => (
</h2> <ShortcutItem key={product.slug} product={product} />
<Image ))}
className={`${expand ? "" : "rotate-180"}`} </div>
src={"/icons/unicorn_angle-up.svg"} ) : null}
width={24}
height={24}
alt=""
/>
</button>
<div
className={`flex flex-col gap-3 py-2 ${!expand ? "hidden " : "block"}`}
>
{products.map((product) => (
<ShortcutItem key={product.slug} product={product} />
))}
</div>
</div> </div>
); );
}); };
export default ShortcutList; export default ShortcutList;

View File

@ -1,109 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import SearchBar from "../SearchBar";
import ShortcutList from "../ShortcutList";
import HistoryList from "../HistoryList";
import { observer } from "mobx-react-lite";
import { useStore } from "@/_models/RootStore";
import Image from "next/image";
import { usePathname } from "next/navigation";
import useGetUserConversations from "@/_hooks/useGetUserConversations";
import DiscordContainer from "../DiscordContainer";
import { useQuery } from "@apollo/client";
import {
GetProductsQuery,
GetProductsDocument,
ProductDetailFragment,
} from "@/graphql";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
export const SidebarLeft: React.FC = observer(() => {
const router = usePathname();
const [searchText, setSearchText] = useState("");
const { user } = useGetCurrentUser();
const { getUserConversations } = useGetUserConversations();
const [featuredProducts, setFeaturedProducts] = useState<
ProductDetailFragment[]
>([]);
const { historyStore } = useStore();
const navigation = ["pricing", "docs", "about"];
const { loading, error, data } =
useQuery<GetProductsQuery>(GetProductsDocument);
const checkRouter = () =>
navigation.map((item) => router?.includes(item)).includes(true);
useEffect(() => {
if (user) {
const createConversationAndActive = async () => {
await getUserConversations(user);
};
createConversationAndActive();
}
}, [user]);
useEffect(() => {
if (data?.products) {
setFeaturedProducts(
[...(data.products || [])]
.sort(() => 0.5 - Math.random())
.slice(0, 3)
.filter(
(e) =>
searchText === "" ||
e.name.toLowerCase().includes(searchText.toLowerCase())
) || []
);
}
}, [data?.products, searchText]);
const onLogoClick = () => {
historyStore.clearActiveConversationId();
};
const onSearching = (text: string) => {
setSearchText(text);
};
return (
<div
className={`${
historyStore.showAdvancedPrompt ? "lg:hidden" : "lg:flex"
} ${
checkRouter() ? "lg:hidden" : "lg:block"
} hidden lg:inset-y-0 lg:w-72 lg:flex-col flex-shrink-0 overflow-hidden border-r border-gray-200 dark:bg-gray-800`}
>
<div className="h-full flex grow flex-col overflow-hidden">
<button className="p-3 flex gap-3" onClick={onLogoClick}>
<div className="flex gap-[2px] items-center">
<Image src={"/icons/app_icon.svg"} width={28} height={28} alt="" />
<Image src={"/icons/Jan.svg"} width={27} height={12} alt="" />
</div>
</button>
<div className="flex flex-col gap-3 overflow-x-hidden h-full">
<div className="flex items-center px-3">
<SearchBar onTextChanged={onSearching} />
</div>
<div className="flex flex-col h-full overflow-x-hidden scroll gap-3">
{data && <ShortcutList products={featuredProducts} />}
{loading && (
<div className="w-full flex flex-row justify-center items-center">
<Image
src="/icons/loading.svg"
width={32}
height={32}
alt="loading"
/>
</div>
)}
<HistoryList searchText={searchText} />
</div>
</div>
</div>
<div className="flex-grow">
<DiscordContainer />
</div>
</div>
);
});

View File

@ -1,105 +1,72 @@
import Image from "next/image"; import Image from "next/image";
import JanImage from "../JanImage"; import JanImage from "../JanImage";
import { displayDate } from "@/_utils/datetime"; import { displayDate } from "@/_utils/datetime";
import Link from "next/link"; import Link from "next/link";
import { useStore } from "@/_models/RootStore";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser"; type Props = {
import { avatarUrl?: string;
CreateMessageMutation, senderName: string;
CreateMessageDocument, text?: string;
GenerateImageMutation, createdAt: number;
GenerateImageDocument, imageUrls: string[];
} from "@/graphql"; };
import { useMutation } from "@apollo/client";
const SimpleImageMessage: React.FC<Props> = ({
type Props = { avatarUrl = "",
avatarUrl?: string; senderName,
senderName: string; imageUrls,
text?: string; text,
createdAt: number; createdAt,
imageUrls: string[]; }) => {
}; // TODO handle regenerate image case
return (
const SimpleImageMessage: React.FC<Props> = ({ <div className="flex items-start gap-2">
avatarUrl = "", <img
senderName, className="rounded-full"
imageUrls, src={avatarUrl}
text, width={32}
createdAt, height={32}
}) => { alt=""
const { historyStore } = useStore(); />
const { user } = useGetCurrentUser(); <div className="flex flex-col gap-1">
const [createMessageMutation] = useMutation<CreateMessageMutation>( <div className="flex gap-1 justify-start items-baseline">
CreateMessageDocument <div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px]">
); {senderName}
const [imageGenerationMutation] = useMutation<GenerateImageMutation>( </div>
GenerateImageDocument <div className="text-[11px] leading-[13.2px] font-medium text-gray-400 ml-2">
); {displayDate(createdAt)}
</div>
const onRegenerate = () => { </div>
if (!user) { <div className="flex items-center gap-3 flex-col">
// TODO: we should show an error here <JanImage
return; imageUrl={imageUrls[0]}
} className="w-72 aspect-square rounded-lg"
/>
historyStore.sendMessage( <div className="flex flex-row justify-start items-start w-full gap-2">
createMessageMutation, <Link
imageGenerationMutation, href={imageUrls[0] || "#"}
text ?? "", target="_blank_"
user.id, className="flex gap-1 items-center px-2 py-1 bg-[#F3F4F6] rounded-[12px]"
senderName, >
avatarUrl <Image src="/icons/download.svg" width={16} height={16} alt="" />
); <span className="leading-[20px] text-[14px] text-[#111928]">
}; Download
</span>
return ( </Link>
<div className="flex items-start gap-2"> <button
<img className="flex gap-1 items-center px-2 py-1 bg-[#F3F4F6] rounded-[12px]"
className="rounded-full" // onClick={() => sendChatMessage()}
src={avatarUrl} >
width={32} <Image src="/icons/refresh.svg" width={16} height={16} alt="" />
height={32} <span className="leading-[20px] text-[14px] text-[#111928]">
alt="" Re-generate
/> </span>
<div className="flex flex-col gap-1"> </button>
<div className="flex gap-1 justify-start items-baseline"> </div>
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px]"> </div>
{senderName} </div>
</div> </div>
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400 ml-2"> );
{displayDate(createdAt)} };
</div>
</div> export default SimpleImageMessage;
<div className="flex items-center gap-3 flex-col">
<JanImage
imageUrl={imageUrls[0]}
className="w-72 aspect-square rounded-lg"
/>
<div className="flex flex-row justify-start items-start w-full gap-2">
<Link
href={imageUrls[0] || "#"}
target="_blank_"
className="flex gap-1 items-center px-2 py-1 bg-[#F3F4F6] rounded-[12px]"
>
<Image src="/icons/download.svg" width={16} height={16} alt="" />
<span className="leading-[20px] text-[14px] text-[#111928]">
Download
</span>
</Link>
<button
className="flex gap-1 items-center px-2 py-1 bg-[#F3F4F6] rounded-[12px]"
onClick={onRegenerate}
>
<Image src="/icons/refresh.svg" width={16} height={16} alt="" />
<span className="leading-[20px] text-[14px] text-[#111928]">
Re-generate
</span>
</button>
</div>
</div>
</div>
</div>
);
};
export default SimpleImageMessage;

View File

@ -1,56 +1,56 @@
import React from "react"; import React from "react";
import { displayDate } from "@/_utils/datetime"; import { displayDate } from "@/_utils/datetime";
import { TextCode } from "../TextCode"; import { TextCode } from "../TextCode";
import { getMessageCode } from "@/_utils/message"; import { getMessageCode } from "@/_utils/message";
import Image from "next/image";
type Props = {
avatarUrl?: string; type Props = {
senderName: string; avatarUrl: string;
createdAt: number; senderName: string;
text?: string; createdAt: number;
}; text?: string;
};
const SimpleTextMessage: React.FC<Props> = ({
senderName, const SimpleTextMessage: React.FC<Props> = ({
createdAt, senderName,
avatarUrl = "", createdAt,
text = "", avatarUrl = "",
}) => { text = "",
return ( }) => (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2 ml-3">
<img <Image
className="rounded-full" className="rounded-full"
src={avatarUrl} src={avatarUrl}
width={32} width={32}
height={32} height={32}
alt="" alt=""
/> />
<div className="flex flex-col gap-1 w-full"> <div className="flex flex-col gap-1 w-full">
<div className="flex gap-1 justify-start items-baseline"> <div className="flex gap-1 justify-start items-baseline">
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px] dark:text-[#d1d5db]"> <div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px] dark:text-[#d1d5db]">
{senderName} {senderName}
</div> </div>
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400"> <div className="text-[11px] leading-[13.2px] font-medium text-gray-400">
{displayDate(createdAt)} {displayDate(createdAt)}
</div> </div>
</div> </div>
{text.includes("```") ? ( {text.includes("```") ? (
getMessageCode(text).map((item, i) => ( getMessageCode(text).map((item, i) => (
<div className="flex gap-1 flex-col" key={i}> <div className="flex gap-1 flex-col" key={i}>
<p className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]"> <p className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]">
{item.text} {item.text}
</p> </p>
{item.code.trim().length > 0 && <TextCode text={item.code} />} {item.code.trim().length > 0 && <TextCode text={item.code} />}
</div> </div>
)) ))
) : ( ) : (
<p className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]"> <p
{text} className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]"
</p> dangerouslySetInnerHTML={{ __html: text }}
)} />
</div> )}
</div> </div>
); </div>
}; );
export default React.memo(SimpleTextMessage); export default React.memo(SimpleTextMessage);

View File

@ -1,13 +1,13 @@
import useCreateConversation from "@/_hooks/useCreateConversation"; import useCreateConversation from "@/_hooks/useCreateConversation";
import { ProductDetailFragment } from "@/graphql"; import { Product } from "@/_models/Product";
import Image from "next/image"; import Image from "next/image";
type Props = { type Props = {
product: ProductDetailFragment; product: Product;
}; };
const Slide: React.FC<Props> = ({ product }) => { const Slide: React.FC<Props> = ({ product }) => {
const { name, image_url, description } = product; const { name, avatarUrl, description } = product;
const { requestCreateConvo } = useCreateConversation(); const { requestCreateConvo } = useCreateConversation();
const onClick = () => { const onClick = () => {
@ -17,9 +17,10 @@ const Slide: React.FC<Props> = ({ product }) => {
return ( return (
<div className="w-full embla__slide h-[435px] relative"> <div className="w-full embla__slide h-[435px] relative">
<Image <Image
className="object-cover w-full h-full embla__slide__img" className="w-full h-auto embla__slide__img"
src={image_url ?? ""} src={avatarUrl}
layout="fill" fill
priority
alt="" alt=""
/> />
<div className="absolute bg-[rgba(0,0,0,0.7)] w-full text-white bottom-0 right-0"> <div className="absolute bg-[rgba(0,0,0,0.7)] w-full text-white bottom-0 right-0">

View File

@ -2,10 +2,10 @@ import { FC, useCallback, useEffect, useState } from "react";
import Slide from "../Slide"; import Slide from "../Slide";
import useEmblaCarousel, { EmblaCarouselType } from "embla-carousel-react"; import useEmblaCarousel, { EmblaCarouselType } from "embla-carousel-react";
import { NextButton, PrevButton } from "../ButtonSlider"; import { NextButton, PrevButton } from "../ButtonSlider";
import { ProductDetailFragment } from "@/graphql"; import { Product } from "@/_models/Product";
type Props = { type Props = {
products: ProductDetailFragment[]; products: Product[];
}; };
const Slider: FC<Props> = ({ products }) => { const Slider: FC<Props> = ({ products }) => {
@ -35,12 +35,12 @@ const Slider: FC<Props> = ({ products }) => {
}, [emblaApi, onSelect]); }, [emblaApi, onSelect]);
return ( return (
<div className="embla rounded-lg overflow-hidden relative"> <div className="embla rounded-lg overflow-hidden relative mt-6 mx-6">
<div className="embla__viewport" ref={emblaRef}> <div className="embla__viewport" ref={emblaRef}>
<div className="embla__container"> <div className="embla__container">
{products.map((product) => { {products.map((product) => (
return <Slide key={product.slug} product={product} />; <Slide key={product.slug} product={product} />
})} ))}
</div> </div>
</div> </div>
<div className="embla__buttons"> <div className="embla__buttons">

View File

@ -1,19 +1,13 @@
import React, { useEffect } from "react"; import React from "react";
import { displayDate } from "@/_utils/datetime"; import { displayDate } from "@/_utils/datetime";
import { useStore } from "@/_models/RootStore"; import { TextCode } from "../TextCode";
import { StreamingText, useTextBuffer } from "nextjs-openai"; import { getMessageCode } from "@/_utils/message";
import { MessageSenderType, MessageStatus } from "@/_models/ChatMessage"; import Image from "next/image";
import { Role } from "@/_models/History"; import { useAtom } from "jotai";
import { useMutation } from "@apollo/client"; import { currentStreamingMessageAtom } from "@/_helpers/JotaiWrapper";
import { OpenAI } from "openai-streams";
import {
UpdateMessageDocument,
UpdateMessageMutation,
UpdateMessageMutationVariables,
} from "@/graphql";
type Props = { type Props = {
id?: string; id: string;
avatarUrl?: string; avatarUrl?: string;
senderName: string; senderName: string;
createdAt: number; createdAt: number;
@ -25,67 +19,13 @@ const StreamTextMessage: React.FC<Props> = ({
senderName, senderName,
createdAt, createdAt,
avatarUrl = "", avatarUrl = "",
text = "",
}) => { }) => {
const [data, setData] = React.useState<any | undefined>(); const [message, _] = useAtom(currentStreamingMessageAtom);
const { historyStore } = useStore();
const conversation = historyStore?.getActiveConversation();
const [updateMessage] = useMutation<UpdateMessageMutation>(
UpdateMessageDocument
);
React.useEffect(() => { return message?.text && message?.text?.length > 0 ? (
if ( <div className="flex items-start gap-2 ml-3">
!conversation || <Image
conversation.chatMessages.findIndex((e) => e.id === id) !==
conversation.chatMessages.length - 1
) {
return;
}
const messages = conversation?.chatMessages
.slice(-10)
.filter((e) => e.id !== id)
.map((e) => ({
role:
e.messageSenderType === MessageSenderType.User
? Role.User
: Role.Assistant,
content: e.text,
}));
setData({
messages,
});
}, [conversation]);
const { buffer, done } = useTextBuffer({
url: `api/openai`,
data,
});
useEffect(() => {
if (done) {
// mutate result
const variables: UpdateMessageMutationVariables = {
id: id,
data: {
content: buffer.join(""),
status: MessageStatus.Ready,
},
};
updateMessage({
variables,
});
}
}, [done]);
useEffect(() => {
if (buffer.length > 0 && conversation?.isWaitingForModelResponse) {
historyStore.finishActiveConversationWaiting();
}
}, [buffer]);
return data ? (
<div className="flex items-start gap-2">
<img
className="rounded-full" className="rounded-full"
src={avatarUrl} src={avatarUrl}
width={32} width={32}
@ -101,9 +41,21 @@ const StreamTextMessage: React.FC<Props> = ({
{displayDate(createdAt)} {displayDate(createdAt)}
</div> </div>
</div> </div>
<div className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]"> {message.text.includes("```") ? (
<StreamingText buffer={buffer} fade={100} /> getMessageCode(message.text).map((item, i) => (
</div> <div className="flex gap-1 flex-col" key={i}>
<p className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]">
{item.text}
</p>
{item.code.trim().length > 0 && <TextCode text={item.code} />}
</div>
))
) : (
<p
className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]"
dangerouslySetInnerHTML={{ __html: message.text }}
/>
)}
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,35 +1,35 @@
import Image from "next/image"; import Image from "next/image";
type Props = { type Props = {
onTabClick: (clickedTab: "description" | "api") => void; onTabClick: (clickedTab: "description" | "api") => void;
tab: string; tab: string;
}; };
export const TabModelDetail: React.FC<Props> = ({ onTabClick, tab }) => { export const TabModelDetail: React.FC<Props> = ({ onTabClick, tab }) => {
const btns = [ const btns = [
{ {
name: "api", name: "api",
icon: "/icons/unicorn_arrow.svg", icon: "/icons/unicorn_arrow.svg",
}, },
{ {
name: "description", name: "description",
icon: "/icons/unicorn_exclamation-circle.svg", icon: "/icons/unicorn_exclamation-circle.svg",
}, },
]; ];
return ( return (
<div className="flex gap-[2px] rounded p-1 w-full bg-gray-200"> <div className="flex gap-[2px] rounded p-1 w-full bg-gray-200">
{btns.map((item, index) => ( {btns.map((item, index) => (
<button <button
key={index} key={index}
onClick={() => onTabClick(item.name as "description" | "api")} onClick={() => onTabClick(item.name as "description" | "api")}
className={`w-1/2 capitalize flex items-center justify-center py-[6px] px-3 gap-2 relative text-sm leading-5 ${ className={`w-1/2 capitalize flex items-center justify-center py-[6px] px-3 gap-2 relative text-sm leading-5 ${
tab !== item.name ? "" : "bg-white rounded shadow-sm" tab !== item.name ? "" : "bg-white rounded shadow-sm"
}`} }`}
> >
<Image src={item.icon} width={20} height={20} alt="" /> <Image src={item.icon} width={20} height={20} alt="" />
{item.name} {item.name}
</button> </button>
))} ))}
</div> </div>
); );
}; };

View File

@ -1,31 +1,31 @@
import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import Image from "next/image"; import Image from "next/image";
type Props = { type Props = {
text: string; text: string;
}; };
export const TextCode: React.FC<Props> = ({ text }) => ( export const TextCode: React.FC<Props> = ({ text }) => (
<div className="w-full rounded-lg overflow-hidden bg-[#1F2A37] mr-3"> <div className="w-full rounded-lg overflow-hidden bg-[#1F2A37] mr-3">
<div className="text-gray-200 bg-gray-800 flex items-center justify-between px-4 py-2 text-xs capitalize"> <div className="text-gray-200 bg-gray-800 flex items-center justify-between px-4 py-2 text-xs capitalize">
<button onClick={() => navigator.clipboard.writeText(text)}> <button onClick={() => navigator.clipboard.writeText(text)}>
<Image <Image
src={"/icons/unicorn_clipboard-alt.svg"} src={"/icons/unicorn_clipboard-alt.svg"}
width={24} width={24}
height={24} height={24}
alt="" alt=""
/> />
</button> </button>
</div> </div>
<SyntaxHighlighter <SyntaxHighlighter
className="w-full overflow-x-hidden resize-none" className="w-full overflow-x-hidden resize-none"
language="jsx" language="jsx"
style={atomOneDark} style={atomOneDark}
customStyle={{ padding: "12px", background: "transparent" }} customStyle={{ padding: "12px", background: "transparent" }}
wrapLongLines={true} wrapLongLines={true}
> >
{text} {text}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
); );

View File

@ -1,11 +1,11 @@
type Props = { type Props = {
title: string; title: string;
}; };
export const TitleBlankState: React.FC<Props> = ({ title }) => { export const TitleBlankState: React.FC<Props> = ({ title }) => {
return ( return (
<h2 className="text-[#6B7280] text-[20px] leading-[25px] tracking-[-0.4px] font-semibold"> <h2 className="text-[#6B7280] text-[20px] leading-[25px] tracking-[-0.4px] font-semibold">
{title} {title}
</h2> </h2>
); );
}; };

View File

@ -0,0 +1,39 @@
import { currentProductAtom, currentPromptAtom } from "@/_helpers/JotaiWrapper";
import { GetProductPromptsQuery, GetProductPromptsDocument } from "@/graphql";
import { useQuery } from "@apollo/client";
import { useAtomValue, useSetAtom } from "jotai";
const TryItYourself = () => {
const setCurrentPrompt = useSetAtom(currentPromptAtom);
const product = useAtomValue(currentProductAtom);
const { data } = useQuery<GetProductPromptsQuery>(GetProductPromptsDocument, {
variables: { productSlug: product?.slug ?? "" },
});
if (!data || data.prompts.length === 0) {
return <div />;
}
const promps = data.prompts;
return (
<div className="flex flex-col gap-4 tracking-[-0.4px] leading-[22px] text-base">
<h2 className="font-bold">Try it yourself</h2>
<ul className="border-[1px] border-[#D1D5DB] rounded-[12px]">
{promps.map((prompt, index) => (
<button
onClick={() => setCurrentPrompt(prompt.content ?? "")}
key={prompt.slug}
className={`text-sm text-gray-500 leading-[20px] flex gap-[10px] border-b-[${
index !== promps.length - 1 ? "1" : "0"
}px] border-[#E5E7EB] hover:text-blue-400 text-left p-3 w-full`}
>
{prompt.content}
</button>
))}
</ul>
</div>
);
};
export default TryItYourself;

View File

@ -1,102 +1,102 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { FieldValues, UseFormRegister } from "react-hook-form"; import { FieldValues, UseFormRegister } from "react-hook-form";
type Props = { type Props = {
register: UseFormRegister<FieldValues>; register: UseFormRegister<FieldValues>;
}; };
export const UploadFileImage: React.FC<Props> = ({ register }) => { export const UploadFileImage: React.FC<Props> = ({ register }) => {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [checked, setChecked] = useState<boolean>(true); const [checked, setChecked] = useState<boolean>(true);
const [fileName, setFileName] = useState<string>("No selected file"); const [fileName, setFileName] = useState<string>("No selected file");
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => { const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
if (!file || file.type.split("/")[0] !== "image") return; if (!file || file.type.split("/")[0] !== "image") return;
setImage(URL.createObjectURL(file)); setImage(URL.createObjectURL(file));
setFileName(file.name); setFileName(file.name);
}; };
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => { const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
}; };
const handleClick = () => { const handleClick = () => {
ref.current?.click(); ref.current?.click();
}; };
const onSelectedFile = (event: React.ChangeEvent<HTMLInputElement>) => { const onSelectedFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const file = files[0]; const file = files[0];
if (file.type.split("/")[0] !== "image") return; if (file.type.split("/")[0] !== "image") return;
setImage(URL.createObjectURL(file)); setImage(URL.createObjectURL(file));
setFileName(file.name); setFileName(file.name);
}; };
const handleDelete = () => { const handleDelete = () => {
setImage(null); setImage(null);
setFileName("No file selected"); setFileName("No file selected");
}; };
return ( return (
<div <div
className={`flex flex-col gap-[10px] py-3`} className={`flex flex-col gap-[10px] py-3`}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
> >
{/* {image ? ( {/* {image ? (
<div className="relative group"> <div className="relative group">
<Image <Image
style={{ width: "100%", height: "107px", objectFit: "cover" }} style={{ width: "100%", height: "107px", objectFit: "cover" }}
src={image} src={image}
width={246} width={246}
height={104} height={104}
alt={fileName} alt={fileName}
/> />
<div className="hidden justify-center items-center absolute top-0 left-0 w-full h-full group-hover:flex group-hover:bg-[rgba(255, 255, 255, 0.2)]"> <div className="hidden justify-center items-center absolute top-0 left-0 w-full h-full group-hover:flex group-hover:bg-[rgba(255, 255, 255, 0.2)]">
<button onClick={handleDelete}>Delete</button> <button onClick={handleDelete}>Delete</button>
</div> </div>
</div> </div>
) : ( */} ) : ( */}
<div <div
onClick={handleClick} onClick={handleClick}
className="flex flex-col justify-center items-center py-5 px-2 gap-2 round-[2px] border border-dashed border-[#C8D0E0] rounded-sm" className="flex flex-col justify-center items-center py-5 px-2 gap-2 round-[2px] border border-dashed border-[#C8D0E0] rounded-sm"
> >
{/* <Image src={"/icons/ic_plus.svg"} width={14} height={14} alt="" /> {/* <Image src={"/icons/ic_plus.svg"} width={14} height={14} alt="" />
<span className="text-gray-700 font-normal text-sm"> <span className="text-gray-700 font-normal text-sm">
Drag an image here, or click to select Drag an image here, or click to select
</span> */} </span> */}
<input <input
{...register("fileInput", { required: true })} {...register("fileInput", { required: true })}
// ref={ref} // ref={ref}
type="file" type="file"
onChange={onSelectedFile} onChange={onSelectedFile}
accept="image/*" accept="image/*"
/> />
</div> </div>
){/* } */} ){/* } */}
<div <div
className="flex gap-2 items-center cursor-pointer" className="flex gap-2 items-center cursor-pointer"
onClick={() => setChecked(!checked)} onClick={() => setChecked(!checked)}
> >
<input <input
checked={checked} checked={checked}
className="rounded" className="rounded"
type="checkbox" type="checkbox"
onChange={() => setChecked(!checked)} onChange={() => setChecked(!checked)}
/> />
<span className="text-sm leading-5 text-[#111928] pointer-events-none"> <span className="text-sm leading-5 text-[#111928] pointer-events-none">
Crop center to fit output resolution Crop center to fit output resolution
</span> </span>
</div> </div>
</div> </div>
); );
}; };

View File

@ -1,13 +1,11 @@
"use client";
import React from "react"; import React from "react";
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
import { MenuHeader } from "../MenuHeader"; import { MenuHeader } from "../MenuHeader";
import useGetCurrentUser from "@/_hooks/useGetCurrentUser"; import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
type Props = { const UserProfileDropDown: React.FC = () => {
onLogOutClick: () => void;
};
const UserProfileDropDown: React.FC<Props> = ({ onLogOutClick }) => {
const { loading, user } = useGetCurrentUser(); const { loading, user } = useGetCurrentUser();
if (loading || !user) { if (loading || !user) {
@ -29,7 +27,7 @@ const UserProfileDropDown: React.FC<Props> = ({ onLogOutClick }) => {
</h2> </h2>
</div> </div>
</Popover.Button> </Popover.Button>
<MenuHeader onLogOutClick={onLogOutClick} /> <MenuHeader />
</Popover> </Popover>
</Popover.Group> </Popover.Group>
); );

View File

@ -1,23 +1,29 @@
import { observer } from "mobx-react-lite"; "use client";
import { useStore } from "@/_models/RootStore";
import { currentConversationAtom } from "@/_helpers/JotaiWrapper";
export const UserToolbar: React.FC = observer(() => { import { useAtomValue } from "jotai";
const { historyStore } = useStore(); import Image from "next/image";
const conversation = historyStore.getActiveConversation();
const UserToolbar: React.FC = () => {
const avatarUrl = conversation?.product.avatarUrl ?? ""; const currentConvo = useAtomValue(currentConversationAtom);
const title = conversation?.product.name ?? "";
const avatarUrl = currentConvo?.product.avatarUrl ?? "";
return ( const title = currentConvo?.product.name ?? "";
<div className="flex items-center gap-3 p-1">
<img return (
className="rounded-full aspect-square w-8 h-8" <div className="flex items-center gap-3 p-1">
src={avatarUrl} <Image
alt="" className="rounded-full aspect-square w-8 h-8"
/> src={avatarUrl}
<span className="flex gap-[2px] leading-6 text-base font-semibold"> alt=""
{title} width={36}
</span> height={36}
</div> />
); <span className="flex gap-[2px] leading-6 text-base font-semibold">
}); {title}
</span>
</div>
);
};
export default UserToolbar;

View File

@ -0,0 +1,215 @@
"use client";
import { ChatMessage, MessageStatus } from "@/_models/ChatMessage";
import { Conversation, ConversationState } from "@/_models/Conversation";
import { Product } from "@/_models/Product";
import { Provider, atom } from "jotai";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
};
export default function JotaiWrapper({ children }: Props) {
return <Provider>{children}</Provider>;
}
const activeConversationIdAtom = atom<string | undefined>(undefined);
export const getActiveConvoIdAtom = atom((get) =>
get(activeConversationIdAtom)
);
export const setActiveConvoIdAtom = atom(
null,
(_get, set, convoId: string | undefined) => {
set(activeConversationIdAtom, convoId);
}
);
export const currentPromptAtom = atom<string>("");
export const showingAdvancedPromptAtom = atom<boolean>(false);
export const showingProductDetailAtom = atom<boolean>(false);
export const showingMobilePaneAtom = atom<boolean>(false);
/**
* Stores all conversations for the current user
*/
export const userConversationsAtom = atom<Conversation[]>([]);
export const currentConversationAtom = atom<Conversation | undefined>((get) =>
get(userConversationsAtom).find((c) => c.id === get(activeConversationIdAtom))
);
export const setConvoUpdatedAtAtom = atom(null, (get, set, convoId: string) => {
const convo = get(userConversationsAtom).find((c) => c.id === convoId);
if (!convo) return;
const newConvo: Conversation = { ...convo, updatedAt: Date.now() };
const newConversations: Conversation[] = get(userConversationsAtom).map((c) =>
c.id === convoId ? newConvo : c
);
set(userConversationsAtom, newConversations);
});
export const currentStreamingMessageAtom = atom<ChatMessage | undefined>(undefined);
export const setConvoLastImageAtom = atom(
null,
(get, set, convoId: string, lastImageUrl: string) => {
const convo = get(userConversationsAtom).find((c) => c.id === convoId);
if (!convo) return;
const newConvo: Conversation = { ...convo, lastImageUrl };
const newConversations: Conversation[] = get(userConversationsAtom).map(
(c) => (c.id === convoId ? newConvo : c)
);
set(userConversationsAtom, newConversations);
}
);
/**
* Stores all conversation states for the current user
*/
export const conversationStatesAtom = atom<Record<string, ConversationState>>(
{}
);
export const currentConvoStateAtom = atom<ConversationState | undefined>(
(get) => {
const activeConvoId = get(activeConversationIdAtom);
if (!activeConvoId) {
console.log("active convo id is undefined");
return undefined;
}
return get(conversationStatesAtom)[activeConvoId];
}
);
export const addNewConversationStateAtom = atom(
null,
(get, set, conversationId: string, state: ConversationState) => {
const currentState = { ...get(conversationStatesAtom) };
currentState[conversationId] = state;
set(conversationStatesAtom, currentState);
}
);
export const updateConversationWaitingForResponseAtom = atom(
null,
(get, set, conversationId: string, waitingForResponse: boolean) => {
const currentState = { ...get(conversationStatesAtom) };
currentState[conversationId] = {
...currentState[conversationId],
waitingForResponse,
};
set(conversationStatesAtom, currentState);
}
);
export const updateConversationHasMoreAtom = atom(
null,
(get, set, conversationId: string, hasMore: boolean) => {
const currentState = { ...get(conversationStatesAtom) };
currentState[conversationId] = { ...currentState[conversationId], hasMore };
set(conversationStatesAtom, currentState);
}
);
/**
* Stores all chat messages for all conversations
*/
export const chatMessages = atom<Record<string, ChatMessage[]>>({});
export const currentChatMessagesAtom = atom<ChatMessage[]>((get) => {
const activeConversationId = get(activeConversationIdAtom);
if (!activeConversationId) return [];
return get(chatMessages)[activeConversationId] ?? [];
});
export const addOldMessagesAtom = atom(
null,
(get, set, newMessages: ChatMessage[]) => {
const currentConvoId = get(activeConversationIdAtom);
if (!currentConvoId) return;
const currentMessages = get(chatMessages)[currentConvoId] ?? [];
const updatedMessages = [...currentMessages, ...newMessages];
const newData: Record<string, ChatMessage[]> = {
...get(chatMessages),
};
newData[currentConvoId] = updatedMessages;
set(chatMessages, newData);
}
);
export const addNewMessageAtom = atom(
null,
(get, set, newMessage: ChatMessage) => {
const currentConvoId = get(activeConversationIdAtom);
if (!currentConvoId) return;
const currentMessages = get(chatMessages)[currentConvoId] ?? [];
const updatedMessages = [newMessage, ...currentMessages];
const newData: Record<string, ChatMessage[]> = {
...get(chatMessages),
};
newData[currentConvoId] = updatedMessages;
set(chatMessages, newData);
}
);
export const updateMessageAtom = atom(
null,
(get, set, id: string, conversationId: string, text: string) => {
const messages = get(chatMessages)[conversationId] ?? [];
const message = messages.find((e) => e.id === id);
if (message) {
message.text = text;
const updatedMessages = [...messages];
const newData: Record<string, ChatMessage[]> = {
...get(chatMessages),
};
newData[conversationId] = updatedMessages;
set(chatMessages, newData);
}
}
);
/**
* For updating the status of the last AI message that is pending
*/
export const updateLastMessageAsReadyAtom = atom(
null,
(get, set, id, text: string) => {
const currentConvoId = get(activeConversationIdAtom);
if (!currentConvoId) return;
const currentMessages = get(chatMessages)[currentConvoId] ?? [];
const messageToUpdate = currentMessages.find((e) => e.id === id);
// if message is not found, do nothing
if (!messageToUpdate) return;
const index = currentMessages.indexOf(messageToUpdate);
const updatedMsg: ChatMessage = {
...messageToUpdate,
status: MessageStatus.Ready,
text: text,
};
currentMessages[index] = updatedMsg;
const newData: Record<string, ChatMessage[]> = {
...get(chatMessages),
};
newData[currentConvoId] = currentMessages;
set(chatMessages, newData);
}
);
export const currentProductAtom = atom<Product | undefined>(
(get) =>
get(userConversationsAtom).find(
(c) => c.id === get(activeConversationIdAtom)
)?.product
);
export const searchAtom = atom<string>("");
// modal atoms
export const showConfirmDeleteConversationModalAtom = atom(false);
export const showConfirmSignOutModalAtom = atom(false);

View File

@ -1,13 +0,0 @@
"use client";
import { Provider, initializeStore } from "@/_models/RootStore";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
};
export const MobxWrapper: React.FC<Props> = ({ children }) => {
const store = initializeStore();
return <Provider value={store}>{children}</Provider>;
};

View File

@ -0,0 +1,19 @@
"use client";
import ConfirmDeleteConversationModal from "@/_components/ConfirmDeleteConversationModal";
import ConfirmSignOutModal from "@/_components/ConfirmSignOutModal";
import MobileMenuPane from "@/_components/MobileMenuPane";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
};
export const ModalWrapper: React.FC<Props> = ({ children }) => (
<>
<MobileMenuPane />
<ConfirmDeleteConversationModal />
<ConfirmSignOutModal />
{children}
</>
);

View File

@ -7,10 +7,9 @@ type Props = {
children: ReactNode; children: ReactNode;
}; };
export const ThemeWrapper: React.FC<Props> = ({ children }) => { // consider to use next-themes or not. This caused the error Warning: Extra attributes from the server: class,style at html after hydration
return ( export const ThemeWrapper: React.FC<Props> = ({ children }) => (
<ThemeProvider enableSystem={false} attribute="class"> <ThemeProvider enableSystem={false} attribute="class">
{children} {children}
</ThemeProvider> </ThemeProvider>
); );
};

View File

@ -1,30 +0,0 @@
import { IStateTreeNode, SnapshotIn } from "mobx-state-tree"
/**
* If you include this in your model in an action() block just under your props,
* it'll allow you to set property values directly while retaining type safety
* and also is executed in an action. This is useful because often you find yourself
* making a lot of repetitive setter actions that only update one prop.
*
* E.g.:
*
* const UserModel = types.model("User")
* .props({
* name: types.string,
* age: types.number
* })
* .actions(withSetPropAction)
*
* const user = UserModel.create({ name: "Jamon", age: 40 })
*
* user.setProp("name", "John") // no type error
* user.setProp("age", 30) // no type error
* user.setProp("age", "30") // type error -- must be number
*/
export const withSetPropAction = <T extends IStateTreeNode>(mstInstance: T) => ({
// generic setter for all properties
setProp<K extends keyof SnapshotIn<T>, V extends SnapshotIn<T>[K]>(field: K, newValue: V) {
// @ts-ignore - for some reason TS complains about this, but it still works fine
mstInstance[field] = newValue
},
})

View File

@ -0,0 +1,16 @@
import {
SubscribeMessageSubscription,
SubscribeMessageDocument,
} from "@/graphql";
import { useSubscription } from "@apollo/client";
const useChatMessageSubscription = (messageId: string) => {
const { data, loading, error } =
useSubscription<SubscribeMessageSubscription>(SubscribeMessageDocument, {
variables: { id: messageId },
});
return { data, loading, error };
};
export default useChatMessageSubscription;

View File

@ -0,0 +1,73 @@
import {
addOldMessagesAtom,
conversationStatesAtom,
currentConversationAtom,
updateConversationHasMoreAtom,
} from "@/_helpers/JotaiWrapper";
import { ChatMessage, toChatMessage } from "@/_models/ChatMessage";
import { MESSAGE_PER_PAGE } from "@/_utils/const";
import {
GetConversationMessagesQuery,
GetConversationMessagesDocument,
GetConversationMessagesQueryVariables,
MessageDetailFragment,
} from "@/graphql";
import { useLazyQuery } from "@apollo/client";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect } from "react";
/**
* Custom hooks to get chat messages for current(active) conversation
*
* @param offset for pagination purpose
* @returns
*/
const useChatMessages = (offset = 0) => {
const addOldChatMessages = useSetAtom(addOldMessagesAtom);
const currentConvo = useAtomValue(currentConversationAtom);
if (!currentConvo) {
throw new Error("activeConversation is null");
}
const convoStates = useAtomValue(conversationStatesAtom);
const updateConvoHasMore = useSetAtom(updateConversationHasMoreAtom);
const [getConversationMessages, { loading, error }] =
useLazyQuery<GetConversationMessagesQuery>(GetConversationMessagesDocument);
useEffect(() => {
const hasMore = convoStates[currentConvo.id]?.hasMore ?? true;
if (!hasMore) return;
const variables: GetConversationMessagesQueryVariables = {
conversation_id: currentConvo.id,
limit: MESSAGE_PER_PAGE,
offset: offset,
};
getConversationMessages({ variables }).then((data) => {
parseMessages(data.data?.messages ?? []).then((newMessages) => {
const isHasMore = newMessages.length === MESSAGE_PER_PAGE;
addOldChatMessages(newMessages);
updateConvoHasMore(currentConvo.id, isHasMore);
});
});
}, [offset, currentConvo.id]);
return {
loading,
error,
hasMore: convoStates[currentConvo.id]?.hasMore ?? true,
};
};
async function parseMessages(
messages: MessageDetailFragment[]
): Promise<ChatMessage[]> {
const newMessages: ChatMessage[] = [];
for (const m of messages) {
const chatMessage = await toChatMessage(m);
newMessages.push(chatMessage);
}
return newMessages;
}
export default useChatMessages;

View File

@ -1,17 +1,27 @@
import { import {
ProductDetailFragment,
CreateConversationMutation, CreateConversationMutation,
CreateConversationDocument, CreateConversationDocument,
CreateConversationMutationVariables, CreateConversationMutationVariables,
} from "@/graphql"; } from "@/graphql";
import { useStore } from "../_models/RootStore";
import useGetCurrentUser from "./useGetCurrentUser"; import useGetCurrentUser from "./useGetCurrentUser";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { MessageSenderType, MessageType } from "@/_models/ChatMessage";
import useSignIn from "./useSignIn"; import useSignIn from "./useSignIn";
import { useAtom, useSetAtom } from "jotai";
import {
addNewConversationStateAtom,
setActiveConvoIdAtom,
userConversationsAtom,
} from "@/_helpers/JotaiWrapper";
import { Conversation } from "@/_models/Conversation";
import { Product } from "@/_models/Product";
import { MessageSenderType, MessageType } from "@/_models/ChatMessage";
const useCreateConversation = () => { const useCreateConversation = () => {
const { historyStore } = useStore(); const [userConversations, setUserConversations] = useAtom(
userConversationsAtom
);
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
const addNewConvoState = useSetAtom(addNewConversationStateAtom);
const { user } = useGetCurrentUser(); const { user } = useGetCurrentUser();
const { signInWithKeyCloak } = useSignIn(); const { signInWithKeyCloak } = useSignIn();
const [createConversation] = useMutation<CreateConversationMutation>( const [createConversation] = useMutation<CreateConversationMutation>(
@ -19,7 +29,7 @@ const useCreateConversation = () => {
); );
const requestCreateConvo = async ( const requestCreateConvo = async (
product: ProductDetailFragment, product: Product,
forceCreate: boolean = false forceCreate: boolean = false
) => { ) => {
if (!user) { if (!user) {
@ -28,15 +38,15 @@ const useCreateConversation = () => {
} }
// search if any fresh convo with particular product id // search if any fresh convo with particular product id
const convo = historyStore.conversations.find( const convo = userConversations.find(
(convo) => (convo) => convo.product.slug === product.slug
convo.product.id === product.slug && convo.chatMessages.length <= 1
); );
if (convo && !forceCreate) { if (convo && !forceCreate) {
historyStore.setActiveConversationId(convo.id); setActiveConvoId(convo.id);
return; return;
} }
const variables: CreateConversationMutationVariables = { const variables: CreateConversationMutationVariables = {
data: { data: {
product_id: product.id, product_id: product.id,
@ -49,7 +59,7 @@ const useCreateConversation = () => {
content: product.greeting || "Hello there 👋", content: product.greeting || "Hello there 👋",
sender: MessageSenderType.Ai, sender: MessageSenderType.Ai,
sender_name: product.name, sender_name: product.name,
sender_avatar_url: product.image_url ?? "", sender_avatar_url: product.avatarUrl,
message_type: MessageType.Text, message_type: MessageType.Text,
message_sender_type: MessageSenderType.Ai, message_sender_type: MessageSenderType.Ai,
}, },
@ -60,14 +70,26 @@ const useCreateConversation = () => {
const result = await createConversation({ const result = await createConversation({
variables, variables,
}); });
const newConvo = result.data?.insert_conversations_one;
if (result.data?.insert_conversations_one) { if (newConvo) {
historyStore.createConversation( const mappedConvo: Conversation = {
result.data.insert_conversations_one, id: newConvo.id,
product, product: product,
user.id, user: {
user.displayName id: user.id,
); displayName: user.displayName,
},
lastTextMessage: newConvo.last_text_message ?? "",
createdAt: Date.now(),
updatedAt: Date.now(),
};
addNewConvoState(newConvo.id, {
hasMore: true,
waitingForResponse: false,
});
setUserConversations([...userConversations, mappedConvo]);
setActiveConvoId(newConvo.id);
} }
// if not found, create new convo and set it as current // if not found, create new convo and set it as current
}; };

View File

@ -0,0 +1,50 @@
import {
currentPromptAtom,
getActiveConvoIdAtom,
setActiveConvoIdAtom,
showingAdvancedPromptAtom,
showingProductDetailAtom,
userConversationsAtom,
} from "@/_helpers/JotaiWrapper";
import {
DeleteConversationDocument,
DeleteConversationMutation,
} from "@/graphql";
import { useMutation } from "@apollo/client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
export default function useDeleteConversation() {
const [userConversations, setUserConversations] = useAtom(
userConversationsAtom
);
const setCurrentPrompt = useSetAtom(currentPromptAtom);
const setShowingProductDetail = useSetAtom(showingProductDetailAtom);
const setShowingAdvancedPrompt = useSetAtom(showingAdvancedPromptAtom);
const activeConvoId = useAtomValue(getActiveConvoIdAtom);
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
const [deleteConversation] = useMutation<DeleteConversationMutation>(
DeleteConversationDocument
);
const deleteConvo = async () => {
if (activeConvoId) {
try {
await deleteConversation({ variables: { id: activeConvoId } });
setUserConversations(
userConversations.filter((c) => c.id !== activeConvoId)
);
setActiveConvoId(undefined);
setCurrentPrompt("");
setShowingProductDetail(false);
setShowingAdvancedPrompt(false);
} catch (err) {
console.error(err);
}
}
};
return {
deleteConvo,
};
}

View File

@ -1,15 +1,15 @@
// @ts-nocheck // @ts-nocheck
import { DefaultUser, User } from "@/_models/User";
import { Instance } from "mobx-state-tree";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useSignOut from "./useSignOut"; import useSignOut from "./useSignOut";
import { DefaultUser, User } from "@/_models/User";
export default function useGetCurrentUser() { export default function useGetCurrentUser() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { signOut } = useSignOut(); const { signOut } = useSignOut();
const [loading, setLoading] = useState(status === "loading"); const [loading, setLoading] = useState(status === "loading");
const [user, setUser] = useState<Instance<typeof User>>(); const [user, setUser] = useState<User>();
useEffect(() => { useEffect(() => {
if ( if (
status !== "loading" && status !== "loading" &&

View File

@ -0,0 +1,24 @@
import { ProductType, toProduct } from "@/_models/Product";
import { GetProductsDocument, GetProductsQuery } from "@/graphql";
import { useQuery } from "@apollo/client";
export default function useGetProducts() {
const { loading, data } = useQuery<GetProductsQuery>(GetProductsDocument, {
variables: { slug: "conversational" },
});
const allProducts = (data?.products ?? []).map((e) => toProduct(e));
const featured = allProducts.sort(() => 0.5 - Math.random()).slice(0, 3);
const conversational = allProducts.filter((e) => e.type === ProductType.LLM);
const generativeArts = allProducts.filter(
(e) => e.type === ProductType.GenerativeArt
);
return {
loading,
featured,
conversational,
generativeArts,
};
}

View File

@ -1,57 +1,35 @@
import { Instance } from "mobx-state-tree";
import { useStore } from "../_models/RootStore";
import { AiModelType } from "../_models/Product";
import { Conversation } from "../_models/Conversation";
import { User } from "../_models/User";
import { GetConversationsQuery, GetConversationsDocument } from "@/graphql"; import { GetConversationsQuery, GetConversationsDocument } from "@/graphql";
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { ConversationState, toConversation } from "@/_models/Conversation";
import { useSetAtom } from "jotai";
import {
conversationStatesAtom,
userConversationsAtom,
} from "@/_helpers/JotaiWrapper";
const useGetUserConversations = () => { const useGetUserConversations = () => {
const { historyStore } = useStore(); const setConversationStates = useSetAtom(conversationStatesAtom);
const setConversations = useSetAtom(userConversationsAtom);
const [getConvos] = useLazyQuery<GetConversationsQuery>( const [getConvos] = useLazyQuery<GetConversationsQuery>(
GetConversationsDocument GetConversationsDocument
); );
const getUserConversations = async (user: Instance<typeof User>) => { const getUserConversations = async () => {
const results = await getConvos(); const results = await getConvos();
if (!results || !results.data || results.data.conversations.length === 0) { if (!results || !results.data || results.data.conversations.length === 0) {
return; return;
} }
const convos = results.data.conversations; const convos = results.data.conversations.map((e) => toConversation(e));
const convoStates: Record<string, ConversationState> = {};
const finalConvo: Instance<typeof Conversation>[] = [];
// mapping
convos.forEach((convo) => { convos.forEach((convo) => {
const conversation = Conversation.create({ convoStates[convo.id] = {
id: convo.id!!, hasMore: true,
product: { waitingForResponse: false,
id: convo.conversation_product?.slug || convo.conversation_product?.id, };
name: convo.conversation_product?.name ?? "",
type:
convo.conversation_product?.inputs.slug === "llm"
? AiModelType.LLM
: convo.conversation_product?.inputs.slug === "sd"
? AiModelType.GenerativeArt
: AiModelType.ControlNet,
avatarUrl: convo.conversation_product?.image_url,
description: convo.conversation_product?.description,
modelDescription: convo.conversation_product?.description,
modelUrl: convo.conversation_product?.source_url,
modelVersion: convo.conversation_product?.version,
},
chatMessages: [],
user: user,
createdAt: new Date(convo.created_at).getTime(),
updatedAt: new Date(convo.updated_at).getTime(),
lastImageUrl: convo.last_image_url ?? "",
lastTextMessage: convo.last_text_message ?? "",
});
finalConvo.push(conversation);
}); });
setConversationStates(convoStates);
historyStore.setConversations(finalConvo); setConversations(convos);
}; };
return { return {

View File

@ -0,0 +1,383 @@
import {
addNewMessageAtom,
currentChatMessagesAtom,
currentConversationAtom,
currentPromptAtom,
currentStreamingMessageAtom,
setConvoLastImageAtom,
setConvoUpdatedAtAtom,
updateConversationWaitingForResponseAtom,
updateMessageAtom,
userConversationsAtom,
} from "@/_helpers/JotaiWrapper";
import {
ChatMessage,
MessageSenderType,
MessageStatus,
MessageType,
} from "@/_models/ChatMessage";
import { Conversation } from "@/_models/Conversation";
import { ProductType } from "@/_models/Product";
import {
CreateMessageDocument,
CreateMessageMutation,
CreateMessageMutationVariables,
GenerateImageDocument,
GenerateImageMutation,
GenerateImageMutationVariables,
UpdateMessageMutation,
UpdateMessageDocument,
UpdateMessageMutationVariables,
UpdateConversationMutation,
UpdateConversationDocument,
UpdateConversationMutationVariables,
} from "@/graphql";
import { useMutation } from "@apollo/client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import useSignIn from "./useSignIn";
import useGetCurrentUser from "./useGetCurrentUser";
import { Role } from "@/_models/User";
export default function useSendChatMessage() {
const { user } = useGetCurrentUser();
const { signInWithKeyCloak } = useSignIn();
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom);
const [userConversations, setUserConversations] = useAtom(
userConversationsAtom
);
const addNewMessage = useSetAtom(addNewMessageAtom);
const activeConversation = useAtomValue(currentConversationAtom);
const currentMessages = useAtomValue(currentChatMessagesAtom);
const [createMessageMutation] = useMutation<CreateMessageMutation>(
CreateMessageDocument
);
const [updateMessageMutation] = useMutation<UpdateMessageMutation>(
UpdateMessageDocument
);
const [updateConversationMutation] = useMutation<UpdateConversationMutation>(
UpdateConversationDocument
);
const [imageGenerationMutation] = useMutation<GenerateImageMutation>(
GenerateImageDocument
);
const updateConvoWaitingState = useSetAtom(
updateConversationWaitingForResponseAtom
);
const updateMessageText = useSetAtom(updateMessageAtom);
const [, setTextMessage] = useAtom(currentStreamingMessageAtom);
const setConvoLastImageUrl = useSetAtom(setConvoLastImageAtom);
const setConvoUpdateAt = useSetAtom(setConvoUpdatedAtAtom);
const sendTextToTextMessage = async (
conversation: Conversation,
latestUserMessage: ChatMessage
) => {
// TODO: handle case timeout using higher order function
const messageToSend = [
latestUserMessage,
...currentMessages.slice(0, 4),
].reverse();
const latestMessages = messageToSend.map((e) => ({
role:
e.messageSenderType === MessageSenderType.User
? Role.User
: Role.Assistant,
content: e.text,
}));
const variables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
sender: MessageSenderType.Ai,
message_sender_type: MessageSenderType.Ai,
message_type: MessageType.Text,
sender_avatar_url: conversation.product.avatarUrl,
sender_name: conversation.product.name,
prompt_cache: latestMessages,
status: MessageStatus.Pending,
},
};
const result = await createMessageMutation({
variables,
});
if (!result.data?.insert_messages_one?.id) {
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
updateConvoWaitingState(conversation.id, false);
return;
}
const aiResponseMessage: ChatMessage = {
id: result.data.insert_messages_one.id,
conversationId: conversation.id,
messageType: MessageType.Text,
messageSenderType: MessageSenderType.Ai,
senderUid: conversation.product.slug,
senderName: conversation.product.name,
senderAvatarUrl: conversation.product.avatarUrl ?? "/icons/app_icon.svg",
text: "",
status: MessageStatus.Pending,
createdAt: Date.now(),
};
setTextMessage(aiResponseMessage);
addNewMessage(aiResponseMessage);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_OPENAPI_ENDPOINT}`,
{
method: "POST",
cache: "no-cache",
keepalive: true,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
messages: latestMessages,
model: "gpt-3.5-turbo",
stream: true,
max_tokens: 500,
}),
}
);
if (!response.ok) {
updateMessageText(
aiResponseMessage.id,
conversation.id,
"There is an error while retrieving the result. Please try again later."
);
} else {
const data = response.body;
if (!data) {
return;
}
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
let currentResponse: string = "";
updateConvoWaitingState(conversation.id, false);
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
chunkValue.split("\n").forEach((chunk) => {
console.log("chunk", chunk);
const text = parsedBuffer(chunk) ?? "";
currentResponse += text;
updateMessageText(
aiResponseMessage.id,
conversation.id,
currentResponse
);
});
}
mutateMessageText(
aiResponseMessage.id,
conversation.id,
currentResponse
);
}
} catch (err) {
const errorText =
"There is an error while retrieving the result. Please try again later.";
updateMessageText(aiResponseMessage.id, conversation.id, errorText);
mutateMessageText(aiResponseMessage.id, conversation.id, errorText);
}
updateConvoWaitingState(conversation.id, false);
};
const sendTextToImageMessage = async (conversation: Conversation) => {
// TODO: handle case timeout using higher order function
const variables: GenerateImageMutationVariables = {
model: conversation.product.slug,
prompt: currentPrompt,
neg_prompt: "",
seed: Math.floor(Math.random() * 429496729),
steps: 30,
width: 512,
height: 512,
};
const data = await imageGenerationMutation({
variables,
});
if (!data.data?.imageGeneration?.url) {
// TODO: display error
console.error("Error creating user message", JSON.stringify(data.errors));
updateConvoWaitingState(conversation.id, false);
return;
}
const imageUrl: string = data.data.imageGeneration.url;
const createMessageVariables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
content: currentPrompt,
sender: MessageSenderType.Ai,
message_sender_type: MessageSenderType.Ai,
message_type: MessageType.Image,
sender_avatar_url: conversation.product.avatarUrl,
sender_name: conversation.product.name,
status: MessageStatus.Ready,
message_medias: {
data: [
{
media_url: imageUrl,
mime_type: "image/jpeg",
},
],
},
},
};
const result = await createMessageMutation({
variables: createMessageVariables,
});
if (!result.data?.insert_messages_one?.id) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
updateConvoWaitingState(conversation.id, false);
return;
}
const imageResponseMessage: ChatMessage = {
id: result.data.insert_messages_one.id,
conversationId: conversation.id,
messageType: MessageType.Image,
messageSenderType: MessageSenderType.Ai,
senderUid: conversation.product.slug,
senderName: conversation.product.name,
senderAvatarUrl: conversation.product.avatarUrl,
text: currentPrompt,
imageUrls: [imageUrl],
createdAt: Date.now(),
status: MessageStatus.Ready,
};
addNewMessage(imageResponseMessage);
setConvoUpdateAt(conversation.id);
setConvoLastImageUrl(conversation.id, imageUrl);
updateConvoWaitingState(conversation.id, false);
};
const sendChatMessage = async () => {
if (!user) {
signInWithKeyCloak();
return;
}
if (currentPrompt.trim().length === 0) return;
if (!activeConversation) {
console.error("No active conversation");
return;
}
updateConvoWaitingState(activeConversation.id, true);
const variables: CreateMessageMutationVariables = {
data: {
conversation_id: activeConversation.id,
content: currentPrompt,
sender: user.id,
message_sender_type: MessageSenderType.User,
message_type: MessageType.Text,
sender_avatar_url: user.avatarUrl,
sender_name: user.displayName,
},
};
const result = await createMessageMutation({ variables });
if (!result.data?.insert_messages_one?.id) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
updateConvoWaitingState(activeConversation.id, false);
return;
}
const userMesssage: ChatMessage = {
id: result.data.insert_messages_one.id,
conversationId: activeConversation.id,
messageType: MessageType.Text,
messageSenderType: MessageSenderType.User,
senderUid: user.id,
senderName: user.displayName,
senderAvatarUrl: user.avatarUrl ?? "/icons/app_icon.svg",
text: currentPrompt,
createdAt: Date.now(),
status: MessageStatus.Ready,
};
addNewMessage(userMesssage);
const newUserConversations = userConversations.map((e) => {
if (e.id === activeConversation.id) {
e.lastTextMessage = userMesssage.text;
}
return e;
});
setUserConversations(newUserConversations);
if (activeConversation.product.type === ProductType.LLM) {
await sendTextToTextMessage(activeConversation, userMesssage);
setCurrentPrompt("");
} else if (activeConversation.product.type === ProductType.GenerativeArt) {
await sendTextToImageMessage(activeConversation);
setCurrentPrompt("");
} else {
console.error(
"We do not support this model type yet:",
activeConversation.product.type
);
}
};
const parsedBuffer = (buffer: string) => {
try {
const json = buffer.replace("data: ", "");
return JSON.parse(json).choices[0].delta.content;
} catch (e) {
return "";
}
};
const mutateMessageText = (
messageId: string,
convId: string,
text: string
) => {
const variables: UpdateMessageMutationVariables = {
data: {
content: text,
status: MessageStatus.Ready,
},
id: messageId,
};
updateMessageMutation({
variables,
});
updateConversationMutation({
variables: {
id: convId,
lastMessageText: text,
},
});
};
return {
sendChatMessage,
};
}

View File

@ -1,12 +1,13 @@
import { signOut as signOutNextAuth } from "next-auth/react"; import { signOut as signOutNextAuth } from "next-auth/react";
export default function useSignOut() { export default function useSignOut() {
const signOut = () => { const signOut = async () => {
fetch(`api/auth/logout`, { method: "GET" }) try {
.then(() => signOutNextAuth({ callbackUrl: "/" })) await fetch(`api/auth/logout`, { method: "GET" });
.catch((e) => { await signOutNextAuth({ callbackUrl: "/" });
console.error(e); } catch (e) {
}); console.error(e);
}
}; };
return { signOut }; return { signOut };

View File

@ -1,5 +1,6 @@
import { types } from "mobx-state-tree"; import { MessageDetailFragment } from "@/graphql";
import { withSetPropAction } from "../_helpers/withSetPropAction"; import { remark } from "remark";
import html from "remark-html";
export enum MessageType { export enum MessageType {
Text = "Text", Text = "Text",
@ -18,18 +19,53 @@ export enum MessageStatus {
Pending = "pending", Pending = "pending",
} }
export const ChatMessage = types export interface ChatMessage {
.model("ChatMessage", { id: string;
id: types.string, conversationId: string;
conversationId: types.string, messageType: MessageType;
messageType: types.enumeration(Object.values(MessageType)), messageSenderType: MessageSenderType;
messageSenderType: types.enumeration(Object.values(MessageSenderType)), senderUid: string;
senderUid: types.string, senderName: string;
senderName: types.string, senderAvatarUrl: string;
senderAvatarUrl: types.maybeNull(types.string), text: string | undefined;
text: types.maybe(types.string), imageUrls?: string[] | undefined;
imageUrls: types.maybe(types.array(types.string)), createdAt: number;
createdAt: types.number, status: MessageStatus;
status: types.enumeration(Object.values(MessageStatus)), }
})
.actions(withSetPropAction); export const toChatMessage = async (
m: MessageDetailFragment
): Promise<ChatMessage> => {
const createdAt = new Date(m.created_at).getTime();
const imageUrls: string[] = [];
const imageUrl =
m.message_medias.length > 0 ? m.message_medias[0].media_url : null;
if (imageUrl) {
imageUrls.push(imageUrl);
}
const messageType = m.message_type
? MessageType[m.message_type as keyof typeof MessageType]
: MessageType.Text;
const messageSenderType = m.message_sender_type
? MessageSenderType[m.message_sender_type as keyof typeof MessageSenderType]
: MessageSenderType.Ai;
const content = m.content ?? "";
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
id: m.id,
conversationId: m.conversation_id,
messageType: messageType,
messageSenderType: messageSenderType,
senderUid: m.sender,
senderName: m.sender_name ?? "",
senderAvatarUrl: m.sender_avatar_url ?? "/icons/app_icon.svg",
text: contentHtml,
imageUrls: imageUrls,
createdAt: createdAt,
status: m.status as MessageStatus,
};
};

View File

@ -1,78 +1,38 @@
import { Instance, castToSnapshot, types } from "mobx-state-tree"; import { ConversationDetailFragment } from "@/graphql";
import { Product } from "./Product"; import { Product, toProduct } from "./Product";
import { ChatMessage } from "./ChatMessage";
import { User } from "../_models/User";
import { withSetPropAction } from "../_helpers/withSetPropAction";
import { mergeAndRemoveDuplicates } from "../_utils/message";
export const Conversation = types export interface Conversation {
.model("Conversation", { id: string;
/** product: Product;
* Unique identifier for the conversation createdAt: number;
*/ updatedAt?: number;
id: types.string, lastImageUrl?: string;
lastTextMessage?: string;
}
/** /**
* AI model that the conversation is using * Store the state of conversation like fetching, waiting for response, etc.
*/ */
product: Product, export type ConversationState = {
hasMore: boolean;
waitingForResponse: boolean;
};
/** export const toConversation = (
* Conversation's messages, should ordered by time (createdAt) convo: ConversationDetailFragment
*/ ): Conversation => {
chatMessages: types.optional(types.array(ChatMessage), []), const product = convo.conversation_product;
if (!product) {
/** throw new Error("Product is not defined");
* User who initiate the chat with the above AI model }
*/ return {
user: User, id: convo.id,
product: toProduct(product),
/** lastImageUrl: convo.last_image_url ?? undefined,
* Indicates whether the conversation is created by the user lastTextMessage: convo.last_text_message ?? undefined,
*/ createdAt: new Date(convo.created_at).getTime(),
createdAt: types.number, updatedAt: convo.updated_at
? new Date(convo.updated_at).getTime()
/** : undefined,
* Time the last message is sent };
*/ };
updatedAt: types.maybe(types.number),
/**
* Last image url sent by the model if any
*/
lastImageUrl: types.maybe(types.string),
/**
* Last text sent by the user if any
*/
lastTextMessage: types.maybe(types.string),
})
.volatile(() => ({
isFetching: false,
offset: 0,
hasMore: true,
isWaitingForModelResponse: false,
}))
.actions(withSetPropAction)
.actions((self) => ({
addMessage(message: Instance<typeof ChatMessage>) {
self.chatMessages.push(message);
},
pushMessages(messages: Instance<typeof ChatMessage>[]) {
const mergedMessages = mergeAndRemoveDuplicates(
self.chatMessages,
messages
);
self.chatMessages = castToSnapshot(mergedMessages);
},
setHasMore(hasMore: boolean) {
self.hasMore = hasMore;
},
setWaitingForModelResponse(isWaitingForModelResponse: boolean) {
self.isWaitingForModelResponse = isWaitingForModelResponse;
},
}));

View File

@ -1,604 +0,0 @@
import { Instance, castToSnapshot, flow, types } from "mobx-state-tree";
import { Conversation } from "./Conversation";
import { Product, AiModelType } from "./Product";
import { User } from "../_models/User";
import {
ChatMessage,
MessageSenderType,
MessageStatus,
MessageType,
} from "./ChatMessage";
import { MESSAGE_PER_PAGE } from "../_utils/const";
import { controlNetRequest } from "@/_services/controlnet";
import {
ApolloCache,
DefaultContext,
FetchResult,
LazyQueryExecFunction,
MutationFunctionOptions,
OperationVariables,
QueryResult,
} from "@apollo/client";
import {
ConversationDetailFragment,
CreateMessageMutation,
CreateMessageMutationVariables,
GenerateImageMutation,
GenerateImageMutationVariables,
GetConversationMessagesQuery,
GetConversationMessagesQueryVariables,
MessageDetailFragment,
ProductDetailFragment,
} from "@/graphql";
export enum Role {
User = "user",
Assistant = "assistant",
}
type CreateMessageMutationFunc = (
options?:
| MutationFunctionOptions<
CreateMessageMutation,
OperationVariables,
DefaultContext,
ApolloCache<any>
>
| undefined
) => Promise<FetchResult<CreateMessageMutation>>;
type ImageGenerationMutationFunc = (
options?:
| MutationFunctionOptions<
GenerateImageMutation,
OperationVariables,
DefaultContext,
ApolloCache<any>
>
| undefined
) => Promise<FetchResult<GenerateImageMutation>>;
export const History = types
.model("History", {
conversations: types.optional(types.array(Conversation), []),
activeConversationId: types.maybe(types.string),
})
.volatile(() => ({
showModelDetail: false,
showAdvancedPrompt: false,
}))
.views((self) => ({
getActiveConversation() {
if (self.activeConversationId) {
return self.conversations.find(
(c) => c.id === self.activeConversationId
);
}
},
getActiveMessages() {
if (self.activeConversationId) {
const conversation = self.conversations.find(
(c) => c.id === self.activeConversationId
);
if (conversation) {
return conversation.chatMessages;
}
}
return [];
},
getConversationById(conversationId: string) {
return self.conversations.find((c) => c.id === conversationId);
},
}))
.actions((self) => ({
// Model detail
toggleModelDetail() {
self.showModelDetail = !self.showModelDetail;
},
toggleAdvancedPrompt() {
self.showAdvancedPrompt = !self.showAdvancedPrompt;
},
closeModelDetail() {
if (self.showModelDetail) {
self.showModelDetail = false;
}
},
finishActiveConversationWaiting() {
self.getActiveConversation()?.setWaitingForModelResponse(false);
},
}))
.actions((self) => {
const fetchMoreMessages = flow(function* (
func: LazyQueryExecFunction<
GetConversationMessagesQuery,
OperationVariables
>
) {
const convoId = self.activeConversationId;
if (!convoId) {
console.error("No active conversation found");
return;
}
const convo = self.getConversationById(convoId);
if (!convo) {
console.error("Could not get convo", convoId);
return;
}
if (convo?.isFetching) {
return;
}
if (!convo.hasMore) {
console.info("Already load all messages of convo", convoId);
return;
}
convo.isFetching = true;
const variables: GetConversationMessagesQueryVariables = {
conversation_id: convoId,
limit: MESSAGE_PER_PAGE,
offset: convo.offset,
};
const result: QueryResult<
GetConversationMessagesQuery,
OperationVariables
> = yield func({ variables });
if (!result.data?.messages) {
return;
}
if (result.data?.messages.length < MESSAGE_PER_PAGE) {
convo.setHasMore(false);
}
convo.offset += result.data.messages.length;
const messages: Instance<typeof ChatMessage>[] = [];
result.data.messages.forEach((m: MessageDetailFragment) => {
const createdAt = new Date(m.created_at).getTime();
const imageUrls: string[] = [];
const imageUrl =
m.message_medias.length > 0 ? m.message_medias[0].media_url : null;
if (imageUrl) {
imageUrls.push(imageUrl);
}
const messageType = m.message_type
? MessageType[m.message_type as keyof typeof MessageType]
: MessageType.Text;
const messageSenderType = m.message_sender_type
? MessageSenderType[
m.message_sender_type as keyof typeof MessageSenderType
]
: MessageSenderType.Ai;
messages.push(
ChatMessage.create({
id: m.id,
conversationId: m.conversation_id,
messageType: messageType,
messageSenderType: messageSenderType,
senderUid: m.sender,
senderName: m.sender_name ?? "",
senderAvatarUrl: m.sender_avatar_url,
text: m.content ?? "",
status: m.status as MessageStatus,
imageUrls: imageUrls,
createdAt: createdAt,
})
);
});
convo.setProp(
"chatMessages",
messages.reverse().concat(convo.chatMessages)
);
convo.isFetching = false;
});
const deleteConversationById = flow(function* (convoId: string) {
const updateConversations = self.conversations.filter(
(c) => c.id !== convoId
);
self.conversations = castToSnapshot([...updateConversations]);
self.activeConversationId = undefined;
});
return {
fetchMoreMessages,
deleteConversationById,
};
})
.actions((self) => {
const setActiveConversationId = flow(function* (
convoId: string | undefined
) {
self.activeConversationId = convoId;
});
return { setActiveConversationId };
})
.actions((self) => ({
clearActiveConversationId() {
self.activeConversationId = undefined;
self.showModelDetail = false;
self.showAdvancedPrompt = false;
},
setConversations(conversations: Instance<typeof Conversation>[]) {
self.conversations = castToSnapshot(conversations);
},
clearAllConversations() {
self.conversations = castToSnapshot([]);
},
}))
.actions((self) => {
const sendTextToTextMessage = flow(function* (
create: CreateMessageMutationFunc,
conversation: Instance<typeof Conversation>
) {
// TODO: handle case timeout using higher order function
const latestMessages = conversation.chatMessages.slice(-5).map((e) => ({
role:
e.messageSenderType === MessageSenderType.User
? Role.User
: Role.Assistant,
content: e.text,
}));
const variables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
content: "",
sender: MessageSenderType.Ai,
message_sender_type: MessageSenderType.Ai,
message_type: MessageType.Text,
sender_avatar_url: conversation.product.avatarUrl,
sender_name: conversation.product.name,
prompt_cache: latestMessages,
status: MessageStatus.Pending,
},
};
const result: FetchResult<CreateMessageMutation> = yield create({
variables,
});
if (!result.data?.insert_messages_one?.id) {
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
conversation.setWaitingForModelResponse(false);
return;
}
const aiResponseMessage = ChatMessage.create({
id: result.data.insert_messages_one.id,
conversationId: conversation.id,
messageType: MessageType.Text,
messageSenderType: MessageSenderType.Ai,
senderUid: conversation.product.id,
senderName: conversation.product.name,
senderAvatarUrl: conversation.product.avatarUrl ?? "",
text: "",
status: MessageStatus.Pending,
createdAt: Date.now(),
});
conversation.addMessage(aiResponseMessage);
});
const sendTextToImageMessage = flow(function* (
create: CreateMessageMutationFunc,
generateImage: ImageGenerationMutationFunc,
message: string,
conversation: Instance<typeof Conversation>
) {
// TODO: handle case timeout using higher order function
// const data = yield api.textToImage(conversation.product.id, message);
const variables: GenerateImageMutationVariables = {
model: conversation.product.id,
prompt: message,
neg_prompt: "",
seed: Math.floor(Math.random() * 429496729),
steps: 30,
width: 512,
height: 512,
};
const data: FetchResult<GenerateImageMutation> = yield generateImage({
variables,
});
if (!data.data?.imageGeneration?.url) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(data.errors)
);
conversation.setWaitingForModelResponse(false);
return;
}
const imageUrl: string = data.data.imageGeneration.url;
const createMessageVariables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
content: message,
sender: MessageSenderType.Ai,
message_sender_type: MessageSenderType.Ai,
message_type: MessageType.Image,
sender_avatar_url: conversation.product.avatarUrl,
sender_name: conversation.product.name,
status: MessageStatus.Ready,
message_medias: {
data: [
{
media_url: imageUrl,
mime_type: "image/jpeg",
},
],
},
},
};
const result: FetchResult<CreateMessageMutation> = yield create({
variables: createMessageVariables,
});
if (!result.data?.insert_messages_one?.id) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
conversation.setWaitingForModelResponse(false);
return;
}
const imageResponseMessage = ChatMessage.create({
id: result.data.insert_messages_one.id,
conversationId: conversation.id,
messageType: MessageType.Image,
messageSenderType: MessageSenderType.Ai,
senderUid: conversation.product.id,
senderName: conversation.product.name,
senderAvatarUrl: conversation.product.avatarUrl,
text: message,
imageUrls: [imageUrl],
createdAt: Date.now(),
status: MessageStatus.Ready,
});
conversation.addMessage(imageResponseMessage);
conversation.setProp("updatedAt", Date.now());
conversation.setProp("lastImageUrl", imageUrl);
conversation.setWaitingForModelResponse(false);
});
return {
sendTextToTextMessage,
sendTextToImageMessage,
};
})
.actions((self) => {
const sendControlNetPrompt = flow(function* (
create: CreateMessageMutationFunc,
prompt: string,
negPrompt: string,
file: any // TODO: file type, for now I don't know what is that
) {
if (!self.activeConversationId) {
console.error("No active conversation found");
return;
}
const conversation = self.getActiveConversation();
if (!conversation) {
console.error(
"No active conversation found with id",
self.activeConversationId
);
return;
}
conversation.setWaitingForModelResponse(true);
const imageUrl = yield controlNetRequest("", prompt, negPrompt, file);
if (!imageUrl || !imageUrl.startsWith("https://")) {
console.error(
"Failed to invoking control net",
self.activeConversationId
);
return;
}
const message = `${prompt}. Negative: ${negPrompt}`;
const variables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
content: message,
sender: MessageSenderType.Ai,
message_sender_type: MessageSenderType.Ai,
message_type: MessageType.ImageWithText,
sender_avatar_url: conversation.product.avatarUrl,
sender_name: conversation.product.name,
message_medias: {
data: [
{
media_url: imageUrl,
},
],
},
},
};
const result: FetchResult<CreateMessageMutation> = yield create({
variables,
});
if (!result.data?.insert_messages_one?.id) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
conversation.setWaitingForModelResponse(false);
return;
}
const chatMessage = ChatMessage.create({
id: result.data.insert_messages_one.id,
conversationId: self.activeConversationId,
messageType: MessageType.ImageWithText,
messageSenderType: MessageSenderType.Ai,
senderUid: conversation.product.id,
senderName: conversation.product.name,
senderAvatarUrl: conversation.product.avatarUrl,
text: message,
imageUrls: [imageUrl],
createdAt: Date.now(),
status: MessageStatus.Ready,
});
conversation.addMessage(chatMessage);
conversation.setProp("lastTextMessage", message);
conversation.setProp("lastImageUrl", imageUrl);
conversation.setWaitingForModelResponse(false);
});
const createConversation = flow(function* (
conversation: ConversationDetailFragment,
product: ProductDetailFragment,
userId: string,
displayName: string,
avatarUrl?: string
) {
let modelType: AiModelType | undefined = undefined;
if (product.inputs.slug === "llm") {
modelType = AiModelType.LLM;
} else if (product.inputs.slug === "sd") {
modelType = AiModelType.GenerativeArt;
} else if (product.inputs.slug === "controlnet") {
modelType = AiModelType.ControlNet;
} else {
console.error("Model type not supported");
return;
}
const productModel = Product.create({
name: product.name,
id: product.slug,
type: modelType,
description: product.description,
modelUrl: product.source_url,
modelVersion: product.version,
avatarUrl: product.image_url,
});
const newConvo = Conversation.create({
id: conversation.id,
product: productModel,
lastTextMessage: conversation.last_text_message ?? "",
user: User.create({
id: userId,
displayName,
avatarUrl,
}),
createdAt: Date.now(),
updatedAt: Date.now(),
});
self.conversations.push(newConvo);
self.activeConversationId = newConvo.id;
});
const sendMessage = flow(function* (
create: CreateMessageMutationFunc,
generateImage: ImageGenerationMutationFunc,
message: string,
userId: string,
displayName: string,
avatarUrl?: string
) {
if (!self.activeConversationId) {
console.error("No active conversation found");
return;
}
const conversation = self.getActiveConversation();
if (!conversation) {
console.error(
"No active conversation found with id",
self.activeConversationId
);
return;
}
conversation.setWaitingForModelResponse(true);
const variables: CreateMessageMutationVariables = {
data: {
conversation_id: conversation.id,
content: message,
sender: userId,
message_sender_type: MessageSenderType.User,
message_type: MessageType.Text,
sender_avatar_url: avatarUrl,
sender_name: displayName,
},
};
const result: FetchResult<CreateMessageMutation> = yield create({
variables,
});
if (!result.data?.insert_messages_one?.id) {
// TODO: display error
console.error(
"Error creating user message",
JSON.stringify(result.errors)
);
conversation.setWaitingForModelResponse(false);
return;
}
const userMesssage = ChatMessage.create({
id: result.data.insert_messages_one.id,
conversationId: self.activeConversationId,
messageType: MessageType.Text,
messageSenderType: MessageSenderType.User,
senderUid: userId,
senderName: displayName,
senderAvatarUrl: avatarUrl,
text: message,
createdAt: Date.now(),
status: MessageStatus.Ready,
});
conversation.addMessage(userMesssage);
conversation.setProp("lastTextMessage", message);
if (conversation.product.type === AiModelType.LLM) {
yield self.sendTextToTextMessage(create, conversation);
} else if (conversation.product.type === AiModelType.GenerativeArt) {
yield self.sendTextToImageMessage(
create,
generateImage,
message,
conversation
);
} else {
console.error(
"We do not support this model type yet:",
conversation.product.type
);
}
});
return {
sendMessage,
sendControlNetPrompt,
createConversation,
};
});

View File

@ -1,19 +0,0 @@
import { types } from "mobx-state-tree";
export const InputHeaderModel = types.model("InputHeader", {
accept: types.maybeNull(types.string),
contentType: types.maybeNull(types.string),
});
export const InputBodyModel = types.model("InputBody", {
name: types.string,
type: types.string,
example: types.maybeNull(types.string),
description: types.string,
});
export const InputModel = types.model("Input", {
slug: types.string,
header: InputHeaderModel,
body: types.array(InputBodyModel),
});

View File

@ -1,14 +0,0 @@
import { types } from "mobx-state-tree";
export const OutputPropertyModel = types.model("OutputProperty", {
name: types.string,
type: types.string,
description: types.string,
});
export const OutputModel = types.model("Output", {
slug: types.string,
type: types.string,
properties: types.maybeNull(types.array(OutputPropertyModel)),
description: types.string,
});

View File

@ -1,22 +1,80 @@
import { types } from "mobx-state-tree"; import { ProductDetailFragment } from "@/graphql";
import { InputModel } from "./Input"; import { ProductInput } from "./ProductInput";
import { OutputModel } from "./Output"; import { ProductOutput } from "./ProductOutput";
export enum AiModelType { export enum ProductType {
LLM = "LLM", LLM = "LLM",
GenerativeArt = "GenerativeArt", GenerativeArt = "GenerativeArt",
ControlNet = "ControlNet", ControlNet = "ControlNet",
} }
export const Product = types.model("Product", { export interface Product {
id: types.string, // TODO change to slug id: string;
name: types.string, slug: string;
type: types.enumeration(Object.values(AiModelType)), name: string;
description: types.maybeNull(types.string), description: string;
avatarUrl: types.maybeNull(types.string), avatarUrl: string;
modelVersion: types.maybeNull(types.string), longDescription: string;
modelUrl: types.maybeNull(types.string), technicalDescription: string;
modelDescription: types.maybeNull(types.string), author: string;
input: types.maybeNull(InputModel), version: string;
output: types.maybeNull(OutputModel), modelUrl: string;
}); nsfw: boolean;
greeting: string;
type: ProductType;
inputs?: ProductInput;
outputs?: ProductOutput;
createdAt: number;
updatedAt?: number;
}
export function toProduct(
productDetailFragment: ProductDetailFragment
): Product {
const {
id,
slug,
name,
description,
image_url,
long_description,
technical_description,
author,
version,
source_url,
nsfw,
greeting,
created_at,
updated_at,
} = productDetailFragment;
let modelType: ProductType | undefined = undefined;
if (productDetailFragment.inputs.slug === "llm") {
modelType = ProductType.LLM;
} else if (productDetailFragment.inputs.slug === "sd") {
modelType = ProductType.GenerativeArt;
} else if (productDetailFragment.inputs.slug === "controlnet") {
modelType = ProductType.ControlNet;
} else {
throw new Error("Model type not supported");
}
const product: Product = {
id,
slug,
name,
description: description ?? "",
avatarUrl: image_url ?? "/icons/app_icon.svg",
longDescription: long_description ?? "",
technicalDescription: technical_description ?? "",
author: author ?? "",
version: version ?? "",
modelUrl: source_url ?? "",
nsfw: nsfw ?? false,
greeting: greeting ?? "",
type: modelType,
createdAt: new Date(created_at).getTime(),
updatedAt: new Date(updated_at).getTime(),
};
return product;
}

View File

@ -0,0 +1,23 @@
export interface ProductInput {
body: ItemProperties[];
slug: string;
headers: ProductHeader;
}
export type ProductHeader = {
accept: string;
contentType: string;
};
export type ItemProperties = {
name: string;
type: string;
items?: ProductBodyItem[];
example?: unknown;
description?: string;
};
export type ProductBodyItem = {
type: string;
properties: ItemProperties[];
};

View File

@ -0,0 +1,8 @@
import { ItemProperties } from "./ProductInput";
export interface ProductOutput {
slug: string;
type: string;
properties: ItemProperties[];
description: string;
}

View File

@ -1,81 +0,0 @@
// Should change name to Product after we remove the old one
import { AiModelType } from "../_models/Product";
export interface Collection {
id: number;
created_at: string;
updated_at: string | undefined;
deleted_at: string | undefined;
slug: CollectionType;
name: string;
description: string;
products: ProductV2[];
}
export interface ProductV2 {
id: number;
created_at: string;
updated_at: string | undefined;
deleted_at: string | undefined;
slug: string;
name: string;
nsfw: boolean;
image_url: string;
description: string;
long_description: string;
technical_description: string;
author: string;
version: string;
source_url: string;
collections: Collection[];
prompts: Prompt[] | undefined;
inputs: InputResponse;
outputs: Record<string, unknown>;
greeting: string;
modelType: AiModelType;
}
export interface OutputResponse {}
export interface InputProperty {
name: string;
type: string;
example: string;
description: string;
}
export interface InputBody {
name: string;
type: string; // TODO make enum for this
items: InputArrayItem[] | undefined;
example: unknown;
description: string;
}
export interface InputArrayItem {
type: string;
properties: InputProperty[];
}
export interface InputResponse {
body: InputBody[];
slug: string;
headers: Record<string, string>;
}
export interface Prompt {
id: number;
created_at: string;
updated_at: string | undefined;
deleted_at: string | undefined;
slug: string;
content: string;
image_url: string | undefined;
products: ProductV2[] | undefined;
}
export type CollectionType = "conversational" | "text-to-image";

View File

@ -1,36 +0,0 @@
import { createContext, useContext } from "react";
import { Instance, types } from "mobx-state-tree";
import { History } from "./History";
import { values } from "mobx";
export const RootStore = types
.model("RootStore", {
historyStore: types.optional(History, {}),
})
.views((self) => ({
get activeConversationId() {
return values(self.historyStore.activeConversationId);
},
get conversations() {
return values(self.historyStore.conversations);
},
}));
export function initializeStore(): RootInstance {
const _store: RootInstance = RootStore.create({});
return _store;
}
export type RootInstance = Instance<typeof RootStore>;
const RootStoreContext = createContext<null | RootInstance>(null);
export const Provider = RootStoreContext.Provider;
export function useStore(): Instance<typeof RootStore> {
const store = useContext(RootStoreContext);
if (store === null) {
throw new Error("Store cannot be null, please add a context provider");
}
return store;
}

View File

@ -1,7 +0,0 @@
import { types } from "mobx-state-tree";
export const Shortcut = types.model("Shortcut", {
name: types.string,
title: types.string,
avatarUrl: types.string,
});

View File

@ -1,15 +1,18 @@
import { types } from "mobx-state-tree"; export interface User {
id: string;
export const User = types.model("User", { displayName: string;
id: types.string, avatarUrl: string;
displayName: types.optional(types.string, "Anonymous"), email?: string;
avatarUrl: types.maybe(types.string), }
email: types.maybe(types.string),
});
export const DefaultUser = { export const DefaultUser = {
id: "0", id: "0",
displayName: "Anonymous", displayName: "Anonymous",
avatarUrl: undefined, avatarUrl: "/icons/app_icon.svg",
email: "", email: "",
}; };
export enum Role {
User = "user",
Assistant = "assistant",
}

Some files were not shown because too many files have changed in this diff Show More